Initial commit: InTono — app KMP per l'apprendimento musicale

App Kotlin Multiplatform (Android/iOS/Desktop) con Compose Multiplatform:
controllo intonazione (YIN), pratica guidata, metronomo, ear training,
sfide, scansione spartiti via AI, spartiti bundled e pentagramma con
glifi tipografici, travature, battute e spaziatura proporzionale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-07-22 10:06:59 +02:00
commit 21ad969b10
245 files changed
+28963

No files matched your search

@@ -0,0 +1,86 @@
package com.notemusicali.audio
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
/**
* Orchestrates the audio pipeline: AudioSource → PitchDetector → NoteConverter.
* Exposes processed results as StateFlow.
*/
class AudioProcessor(
preset: InstrumentPreset = InstrumentPreset.VIOLINO,
) {
private var audioSource: AudioSource = createAudioSource(bufferSize = preset.bufferSize)
private var pitchDetector: PitchDetector = PitchDetector(
bufferSize = preset.bufferSize,
threshold = preset.yinThreshold,
minFrequency = preset.minFrequency,
maxFrequency = preset.maxFrequency,
)
fun updatePreset(preset: InstrumentPreset) {
stop()
audioSource = createAudioSource(bufferSize = preset.bufferSize)
pitchDetector = PitchDetector(
bufferSize = preset.bufferSize,
threshold = preset.yinThreshold,
minFrequency = preset.minFrequency,
maxFrequency = preset.maxFrequency,
)
}
sealed class ProcessedResult {
data object Silence : ProcessedResult()
data class Detected(
val noteResult: NoteConverter.NoteResult,
val confidence: Float,
val rmsAmplitude: Float,
) : ProcessedResult()
data class NoNote(
val rmsAmplitude: Float,
) : ProcessedResult()
}
private val _result = MutableStateFlow<ProcessedResult>(ProcessedResult.Silence)
val result: StateFlow<ProcessedResult> = _result.asStateFlow()
private var job: Job? = null
fun start(scope: CoroutineScope) {
stop()
pitchDetector.reset()
job = scope.launch {
audioSource.audioFlow()
.flowOn(Dispatchers.Default)
.collect { buffer ->
val pitchResult = pitchDetector.detect(buffer)
_result.value = when {
pitchResult.rmsAmplitude < 0.01f -> ProcessedResult.Silence
pitchResult.isValid -> {
val noteResult = NoteConverter.frequencyToNote(pitchResult.frequency)
ProcessedResult.Detected(
noteResult = noteResult,
confidence = pitchResult.confidence,
rmsAmplitude = pitchResult.rmsAmplitude,
)
}
else -> ProcessedResult.NoNote(pitchResult.rmsAmplitude)
}
}
}
}
fun stop() {
job?.cancel()
job = null
_result.value = ProcessedResult.Silence
}
}
@@ -0,0 +1,15 @@
package com.notemusicali.audio
import kotlinx.coroutines.flow.Flow
/**
* Platform-agnostic audio source that provides audio buffers as a Flow.
*/
interface AudioSource {
fun audioFlow(): Flow<FloatArray>
}
/**
* Creates a platform-specific audio source.
*/
expect fun createAudioSource(sampleRate: Int = 44100, bufferSize: Int = 2048): AudioSource
@@ -0,0 +1,77 @@
package com.notemusicali.audio
enum class InstrumentPreset(
val displayName: String,
val minFrequency: Float,
val maxFrequency: Float,
val yinThreshold: Float,
val bufferSize: Int,
val minOctave: Int,
val maxOctave: Int,
val description: String,
) {
VIOLINO(
displayName = "Violino",
minFrequency = 180f,
maxFrequency = 2700f,
yinThreshold = 0.15f,
bufferSize = 2048,
minOctave = 3,
maxOctave = 7,
description = "G3-E7 \u2022 Ottimizzato per archi",
),
VIOLA(
displayName = "Viola",
minFrequency = 120f,
maxFrequency = 1800f,
yinThreshold = 0.15f,
bufferSize = 2048,
minOctave = 3,
maxOctave = 6,
description = "C3-A6 \u2022 Ottimizzato per archi",
),
VIOLONCELLO(
displayName = "Violoncello",
minFrequency = 60f,
maxFrequency = 1100f,
yinThreshold = 0.15f,
bufferSize = 4096,
minOctave = 2,
maxOctave = 6,
description = "C2-C6 \u2022 Buffer ampio per note basse",
),
PIANOFORTE(
displayName = "Pianoforte",
minFrequency = 27f,
maxFrequency = 4200f,
yinThreshold = 0.10f,
bufferSize = 4096,
minOctave = 0,
maxOctave = 8,
description = "A0-C8 \u2022 Soglia bassa, segnale pulito",
),
CHITARRA(
displayName = "Chitarra",
minFrequency = 78f,
maxFrequency = 1400f,
yinThreshold = 0.12f,
bufferSize = 2048,
minOctave = 2,
maxOctave = 6,
description = "E2-E6 \u2022 Corde pizzicate",
),
VOCE(
displayName = "Voce",
minFrequency = 75f,
maxFrequency = 1200f,
yinThreshold = 0.15f,
bufferSize = 2048,
minOctave = 2,
maxOctave = 6,
description = "D2-D6 \u2022 Range vocale esteso",
),
;
val octaveRange: IntRange get() = minOctave..maxOctave
}
@@ -0,0 +1,55 @@
package com.notemusicali.audio
import com.notemusicali.music.ItalianNotation
import com.notemusicali.music.MusicalNote
import kotlin.math.log2
import kotlin.math.pow
import kotlin.math.roundToInt
/**
* Converts frequency (Hz) to musical note information.
* Uses A4 = 440Hz as reference pitch.
*/
object NoteConverter {
private const val A4_FREQUENCY = 440.0
private const val A4_MIDI = 69
data class NoteResult(
val note: MusicalNote,
val centsDeviation: Float,
val frequency: Float,
)
fun frequencyToNote(frequency: Float): NoteResult {
val midiNumber = frequencyToMidi(frequency)
val roundedMidi = midiNumber.roundToInt()
val centsDeviation = ((midiNumber - roundedMidi) * 100).toFloat()
val noteIndex = ((roundedMidi % 12) + 12) % 12
val octave = (roundedMidi / 12) - 1
val italianName = ItalianNotation.fromNoteIndex(noteIndex)
val note = MusicalNote(
noteIndex = noteIndex,
octave = octave,
italianName = italianName,
midiNumber = roundedMidi,
)
return NoteResult(
note = note,
centsDeviation = centsDeviation,
frequency = frequency,
)
}
private fun frequencyToMidi(frequency: Float): Double {
return A4_MIDI + 12.0 * log2(frequency.toDouble() / A4_FREQUENCY)
}
fun midiToFrequency(midiNumber: Int): Float {
return (A4_FREQUENCY * 2.0.pow((midiNumber - A4_MIDI).toDouble() / 12.0)).toFloat()
}
}
@@ -0,0 +1,150 @@
package com.notemusicali.audio
import kotlin.math.abs
/**
* YIN algorithm implementation for pitch detection.
* Optimized for violin range (180Hz-2700Hz, G3-E7).
*
* The algorithm follows 5 steps:
* 1. Difference function
* 2. Cumulative mean normalized difference (CMND)
* 3. Absolute threshold
* 4. Parabolic interpolation
* 5. Hz conversion
*/
class PitchDetector(
private val sampleRate: Int = 44100,
private val bufferSize: Int = 2048,
private val threshold: Float = 0.15f,
private val minFrequency: Float = 180f,
private val maxFrequency: Float = 2700f,
private val confidenceThreshold: Float = 0.7f,
) {
private val maxLag = (sampleRate / minFrequency).toInt().coerceAtMost(bufferSize / 2)
private val minLag = (sampleRate / maxFrequency).toInt().coerceAtLeast(2)
private val recentPitches = ArrayDeque<Float>(3)
data class PitchResult(
val frequency: Float,
val confidence: Float,
val rmsAmplitude: Float,
) {
// detect() only emits frequency > 0 after applying confidenceThreshold,
// so validity must not re-check confidence against a hardcoded value
val isValid: Boolean get() = frequency > 0f
}
fun detect(audioBuffer: FloatArray): PitchResult {
val rms = computeRms(audioBuffer)
if (rms < 0.01f) {
return PitchResult(0f, 0f, rms)
}
// Step 1: Difference function
val diff = differenceFunction(audioBuffer)
// Step 2: Cumulative mean normalized difference
val cmnd = cumulativeMeanNormalizedDifference(diff)
// Step 3: Absolute threshold
val lag = absoluteThreshold(cmnd) ?: return PitchResult(0f, 0f, rms)
// Step 4: Parabolic interpolation
val refinedLag = parabolicInterpolation(cmnd, lag)
// Step 5: Hz conversion
val frequency = sampleRate.toFloat() / refinedLag
val confidence = 1f - cmnd[lag]
if (frequency < minFrequency || frequency > maxFrequency || confidence < confidenceThreshold) {
return PitchResult(0f, confidence, rms)
}
// Median filter on last 3 detections
val filteredFrequency = medianFilter(frequency)
return PitchResult(filteredFrequency, confidence, rms)
}
private fun computeRms(buffer: FloatArray): Float {
var sum = 0f
for (sample in buffer) {
sum += sample * sample
}
return kotlin.math.sqrt(sum / buffer.size)
}
private fun differenceFunction(buffer: FloatArray): FloatArray {
val diff = FloatArray(maxLag + 1)
for (tau in 1..maxLag) {
var sum = 0f
for (i in 0 until bufferSize - tau) {
val delta = buffer[i] - buffer[i + tau]
sum += delta * delta
}
diff[tau] = sum
}
return diff
}
private fun cumulativeMeanNormalizedDifference(diff: FloatArray): FloatArray {
val cmnd = FloatArray(diff.size)
cmnd[0] = 1f
var runningSum = 0f
for (tau in 1 until diff.size) {
runningSum += diff[tau]
cmnd[tau] = if (runningSum != 0f) {
diff[tau] * tau / runningSum
} else {
1f
}
}
return cmnd
}
private fun absoluteThreshold(cmnd: FloatArray): Int? {
// Find first dip below threshold after minLag
var tau = minLag
while (tau < cmnd.size) {
if (cmnd[tau] < threshold) {
// Walk to the minimum of this dip
while (tau + 1 < cmnd.size && cmnd[tau + 1] < cmnd[tau]) {
tau++
}
return tau
}
tau++
}
return null
}
private fun parabolicInterpolation(cmnd: FloatArray, tau: Int): Float {
if (tau < 1 || tau >= cmnd.size - 1) return tau.toFloat()
val s0 = cmnd[tau - 1]
val s1 = cmnd[tau]
val s2 = cmnd[tau + 1]
val adjustment = (s2 - s0) / (2f * (2f * s1 - s2 - s0))
return if (abs(adjustment) < 1f) tau + adjustment else tau.toFloat()
}
private fun medianFilter(frequency: Float): Float {
recentPitches.addLast(frequency)
if (recentPitches.size > 3) {
recentPitches.removeFirst()
}
if (recentPitches.size < 3) return frequency
val sorted = recentPitches.toList().sorted()
return sorted[1]
}
fun reset() {
recentPitches.clear()
}
}
@@ -0,0 +1,24 @@
package com.notemusicali.audio
/**
* Platform-agnostic tone generator for producing audio output.
* Used by metronome, ear training, and reference note features.
*/
interface ToneGenerator {
/** Play a sine wave tone at the given frequency for the specified duration in milliseconds. */
fun playTone(frequencyHz: Float, durationMs: Long)
/** Play a short click sound. If accent is true, play a louder/higher click. */
fun playClick(accent: Boolean = false)
/** Stop any currently playing sound. */
fun stop()
/** Release all audio resources. Call when done using the generator. */
fun release()
}
/**
* Creates a platform-specific ToneGenerator.
*/
expect fun createToneGenerator(sampleRate: Int = 44100): ToneGenerator
@@ -0,0 +1,131 @@
package com.notemusicali.audio
import kotlin.math.PI
import kotlin.math.exp
import kotlin.math.sin
/**
* Shared waveform generator with additive synthesis and ADSR envelopes.
* Produces piano-like tones and realistic metronome clicks.
*/
object WaveGenerator {
// Piano-like harmonic series: (multiplier, relative amplitude)
// Mimics the overtone structure of a struck string
private val pianoHarmonics = floatArrayOf(
1f, 1.0f,
2f, 0.50f,
3f, 0.28f,
4f, 0.14f,
5f, 0.09f,
6f, 0.05f,
7f, 0.03f,
8f, 0.015f,
)
private const val NUM_HARMONICS = 8
private const val HARMONIC_SUM = 2.085f // sum of amplitudes for normalization
private const val TWO_PI = 2.0 * PI
/**
* Generate a piano-like tone with additive synthesis and ADSR envelope.
*/
fun generateToneSamples(
frequencyHz: Float,
durationMs: Long,
sampleRate: Int,
): FloatArray {
val totalSamples = (sampleRate * durationMs / 1000).toInt()
val samples = FloatArray(totalSamples)
// ADSR envelope (in samples)
val attackLen = (sampleRate * 0.008).toInt() // 8ms attack
val decayLen = (sampleRate * 0.12).toInt() // 120ms decay
val sustainLevel = 0.50
val releaseLen = (sampleRate * 0.06).toInt() // 60ms release
val sustainEnd = totalSamples - releaseLen
val amplitude = 0.70
// Per-harmonic: progressively faster decay for higher harmonics (natural behavior)
for (i in 0 until totalSamples) {
// ADSR envelope
val env = when {
i < attackLen -> i.toDouble() / attackLen
i < attackLen + decayLen -> {
val progress = (i - attackLen).toDouble() / decayLen
1.0 - (1.0 - sustainLevel) * progress
}
i >= sustainEnd -> {
val progress = (i - sustainEnd).toDouble() / releaseLen
sustainLevel * (1.0 - progress)
}
else -> sustainLevel
}
// Additive synthesis
var sample = 0.0
var idx = 0
while (idx < NUM_HARMONICS) {
val mult = pianoHarmonics[idx * 2]
val amp = pianoHarmonics[idx * 2 + 1]
val harmonicFreq = frequencyHz * mult
// Anti-aliasing: skip harmonics above Nyquist frequency
if (harmonicFreq > sampleRate / 2) break
// Higher harmonics decay faster over time
val harmonicDecay = exp(-i.toDouble() / sampleRate * mult * 1.2)
sample += amp * harmonicDecay * sin(TWO_PI * harmonicFreq * i / sampleRate)
idx++
}
// Normalize and apply envelope
samples[i] = (sample / HARMONIC_SUM * env * amplitude).toFloat()
}
return samples
}
/**
* Generate a realistic metronome click with tonal + noise components.
* Uses inharmonic overtones for a wood-block-like sound.
*/
fun generateClickSamples(
accent: Boolean,
sampleRate: Int,
): FloatArray {
val durationMs = if (accent) 40 else 30
val totalSamples = sampleRate * durationMs / 1000
val samples = FloatArray(totalSamples)
val toneFreq = if (accent) 1800.0 else 1200.0
val amplitude = if (accent) 0.80 else 0.60
val noiseAmount = 0.25
// Simple deterministic pseudo-random for noise (reproducible across platforms)
var noiseState = 48271L
for (i in 0 until totalSamples) {
val t = i.toDouble() / sampleRate
// Sharp exponential decay with initial transient
val progress = i.toDouble() / totalSamples
val decay = exp(-progress * 8.0)
// Tonal component: inharmonic overtones for wood-like timbre
val tone = sin(TWO_PI * toneFreq * t) +
0.45 * sin(TWO_PI * toneFreq * 2.4 * t) +
0.20 * sin(TWO_PI * toneFreq * 4.1 * t) +
0.10 * sin(TWO_PI * toneFreq * 5.7 * t)
// Noise component (linear congruential generator)
noiseState = (noiseState * 1103515245L + 12345L) and 0x7FFFFFFFL
val noise = noiseState.toDouble() / 0x7FFFFFFFL * 2.0 - 1.0
// Mix tone and noise, then apply decay
val mixed = (1.0 - noiseAmount) * tone / 1.75 + noiseAmount * noise
samples[i] = (mixed * decay * amplitude).toFloat()
}
return samples
}
}
@@ -0,0 +1,70 @@
package com.notemusicali.challenge
import kotlinx.serialization.Serializable
import kotlin.math.abs
import kotlin.math.max
@Serializable
data class ChallengeResult(
val id: String,
val dateMillis: Long,
val sequenceName: String,
val timeLimitSec: Int,
val totalScore: Int,
val notesPlayed: Int,
val maxCombo: Int,
val accuracy: Float,
)
enum class ChallengeTimeLimit(val seconds: Int, val displayName: String) {
SHORT(30, "30s"),
MEDIUM(60, "1 min"),
LONG(120, "2 min"),
}
object ChallengeScoring {
private const val BASE_POINTS = 100
private const val PERFECT_BONUS = 50 // ≤5 cents
private const val GOOD_BONUS = 25 // ≤15 cents
private const val SPEED_BONUS = 30 // <2s reaction
/**
* Score a single note. Returns points earned.
* @param centsDeviation absolute cents deviation
* @param reactionTimeMs time to play the note
* @param comboStreak current combo streak (0-based)
* @param firstAttempt true if played correctly on first try
*/
fun scoreNote(
centsDeviation: Float,
reactionTimeMs: Long,
comboStreak: Int,
firstAttempt: Boolean,
): Int {
if (!firstAttempt) return 0
val absCents = abs(centsDeviation)
var points = BASE_POINTS
// Accuracy bonus
when {
absCents <= 5f -> points += PERFECT_BONUS
absCents <= 15f -> points += GOOD_BONUS
}
// Speed bonus
if (reactionTimeMs < 2000) {
points += SPEED_BONUS
}
// Combo multiplier: 1.0x base, +0.1x per streak note (max 2.0x)
val multiplier = 1.0f + (comboStreak * 0.1f).coerceAtMost(1.0f)
return (points * multiplier).toInt()
}
/**
* Session accuracy: fraction of played notes that were correct on the first attempt.
*/
fun accuracy(notesPlayed: Int, firstAttemptNotes: Int): Float =
if (notesPlayed > 0) (firstAttemptNotes.toFloat() / notesPlayed).coerceAtMost(1f) else 0f
}
@@ -0,0 +1,29 @@
package com.notemusicali.challenge
import com.notemusicali.scan.AppSettings
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object ChallengeRepository {
private const val KEY = "challenge_results"
private const val MAX_RESULTS = 20
private val json = Json { ignoreUnknownKeys = true }
fun load(): List<ChallengeResult> {
val raw = AppSettings.getString(KEY) ?: return emptyList()
return try {
json.decodeFromString<List<ChallengeResult>>(raw)
} catch (_: Exception) { emptyList() }
}
fun save(result: ChallengeResult) {
val current = load().toMutableList()
current.add(result)
current.sortByDescending { it.totalScore }
if (current.size > MAX_RESULTS) {
current.subList(MAX_RESULTS, current.size).clear()
}
AppSettings.putString(KEY, json.encodeToString(current))
}
}
@@ -0,0 +1,111 @@
package com.notemusicali.eartraining
import com.notemusicali.audio.NoteConverter
import com.notemusicali.music.ItalianNotation
import kotlin.random.Random
enum class IntervalType(val semitones: Int, val displayName: String) {
UNISON(0, "Unisono"),
MINOR_2ND(1, "Seconda min."),
MAJOR_2ND(2, "Seconda magg."),
MINOR_3RD(3, "Terza min."),
MAJOR_3RD(4, "Terza magg."),
PERFECT_4TH(5, "Quarta giusta"),
TRITONE(6, "Tritono"),
PERFECT_5TH(7, "Quinta giusta"),
MINOR_6TH(8, "Sesta min."),
MAJOR_6TH(9, "Sesta magg."),
MINOR_7TH(10, "Settima min."),
MAJOR_7TH(11, "Settima magg."),
OCTAVE(12, "Ottava"),
}
enum class EarTrainingMode(val displayName: String) {
NOTE("Riconosci Nota"),
INTERVAL("Riconosci Intervallo"),
}
enum class EarTrainingDifficulty(val displayName: String) {
EASY("Facile"),
MEDIUM("Medio"),
HARD("Difficile"),
}
data class EarTrainingQuestion(
val mode: EarTrainingMode,
val referenceMidi: Int,
val targetMidi: Int,
val correctAnswer: String,
val choices: List<String>,
val interval: IntervalType? = null,
) {
val referenceFrequency: Float get() = NoteConverter.midiToFrequency(referenceMidi)
val targetFrequency: Float get() = NoteConverter.midiToFrequency(targetMidi)
}
object EarTrainingEngine {
private val NOTE_NAMES = (0..11).map { ItalianNotation.fromNoteIndex(it).display }
fun generateNoteQuestion(difficulty: EarTrainingDifficulty): EarTrainingQuestion {
val midiRange = when (difficulty) {
EarTrainingDifficulty.EASY -> 60..72 // C4-C5 (one octave)
EarTrainingDifficulty.MEDIUM -> 55..79 // G3-G5
EarTrainingDifficulty.HARD -> 48..84 // C3-C6
}
val targetMidi = Random.nextInt(midiRange.first, midiRange.last + 1)
val targetNoteIndex = ((targetMidi % 12) + 12) % 12
val correctName = NOTE_NAMES[targetNoteIndex]
// Generate 3 wrong choices
val wrongChoices = mutableSetOf<String>()
while (wrongChoices.size < 3) {
val wrong = NOTE_NAMES[Random.nextInt(12)]
if (wrong != correctName) wrongChoices.add(wrong)
}
val choices = (wrongChoices.toList() + correctName).shuffled()
return EarTrainingQuestion(
mode = EarTrainingMode.NOTE,
referenceMidi = targetMidi,
targetMidi = targetMidi,
correctAnswer = correctName,
choices = choices,
)
}
fun generateIntervalQuestion(difficulty: EarTrainingDifficulty): EarTrainingQuestion {
val availableIntervals = when (difficulty) {
EarTrainingDifficulty.EASY -> listOf(
IntervalType.UNISON, IntervalType.MAJOR_3RD,
IntervalType.PERFECT_5TH, IntervalType.OCTAVE,
)
EarTrainingDifficulty.MEDIUM -> listOf(
IntervalType.MAJOR_2ND, IntervalType.MINOR_3RD, IntervalType.MAJOR_3RD,
IntervalType.PERFECT_4TH, IntervalType.PERFECT_5TH,
IntervalType.MINOR_7TH, IntervalType.OCTAVE,
)
EarTrainingDifficulty.HARD -> IntervalType.entries
}
val interval = availableIntervals.random()
val baseMidi = Random.nextInt(60, 73) // C4-C5
val targetMidi = baseMidi + interval.semitones
// Generate wrong choices
val wrongIntervals = availableIntervals.filter { it != interval }.shuffled().take(3)
val choices = (wrongIntervals.map { it.displayName } + interval.displayName).shuffled()
return EarTrainingQuestion(
mode = EarTrainingMode.INTERVAL,
referenceMidi = baseMidi,
targetMidi = targetMidi,
correctAnswer = interval.displayName,
choices = choices,
interval = interval,
)
}
}
@@ -0,0 +1,42 @@
package com.notemusicali.eartraining
import com.notemusicali.scan.AppSettings
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class EarTrainingSession(
val id: String,
val dateMillis: Long,
val mode: String,
val difficulty: String,
val totalQuestions: Int,
val correctAnswers: Int,
) {
val accuracy: Float get() = if (totalQuestions == 0) 0f
else correctAnswers.toFloat() / totalQuestions
}
object EarTrainingStatsRepository {
private const val KEY = "ear_training_sessions"
private const val MAX_SESSIONS = 50
private val json = Json { ignoreUnknownKeys = true }
fun load(): List<EarTrainingSession> {
val raw = AppSettings.getString(KEY) ?: return emptyList()
return try {
json.decodeFromString<List<EarTrainingSession>>(raw)
} catch (_: Exception) { emptyList() }
}
fun save(session: EarTrainingSession) {
val current = load().toMutableList()
current.add(0, session)
if (current.size > MAX_SESSIONS) {
current.subList(MAX_SESSIONS, current.size).clear()
}
AppSettings.putString(KEY, json.encodeToString(current))
}
}
@@ -0,0 +1,129 @@
package com.notemusicali.exercises
import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteSequence
data class Exercise(
val id: Int,
val title: String,
val description: String,
val level: Int,
val sequence: NoteSequence,
)
object ExerciseRepository {
fun getAllExercises(): List<NoteSequence> = all.map { it.sequence }
val all: List<Exercise> = listOf(
Exercise(
id = 1,
title = "Prime Note",
description = "5 note naturali base",
level = 1,
sequence = NoteSequence(
name = "Prime Note",
notes = listOf(
MusicalNote.fromNameAndOctave(0, 4), // DO4
MusicalNote.fromNameAndOctave(2, 4), // RE4
MusicalNote.fromNameAndOctave(4, 4), // MI4
MusicalNote.fromNameAndOctave(5, 4), // FA4
MusicalNote.fromNameAndOctave(7, 4), // SOL4
),
),
),
Exercise(
id = 2,
title = "Scala di DO Maggiore",
description = "Scala completa su un'ottava",
level = 2,
sequence = NoteSequence(
name = "Scala di DO Maggiore",
notes = listOf(
MusicalNote.fromNameAndOctave(0, 4), // DO4
MusicalNote.fromNameAndOctave(2, 4), // RE4
MusicalNote.fromNameAndOctave(4, 4), // MI4
MusicalNote.fromNameAndOctave(5, 4), // FA4
MusicalNote.fromNameAndOctave(7, 4), // SOL4
MusicalNote.fromNameAndOctave(9, 4), // LA4
MusicalNote.fromNameAndOctave(11, 4), // SI4
MusicalNote.fromNameAndOctave(0, 5), // DO5
),
),
),
Exercise(
id = 3,
title = "Scala di SOL Maggiore",
description = "Introduce FA# e cambio ottava",
level = 3,
sequence = NoteSequence(
name = "Scala di SOL Maggiore",
notes = listOf(
MusicalNote.fromNameAndOctave(7, 3), // SOL3
MusicalNote.fromNameAndOctave(9, 3), // LA3
MusicalNote.fromNameAndOctave(11, 3), // SI3
MusicalNote.fromNameAndOctave(0, 4), // DO4
MusicalNote.fromNameAndOctave(2, 4), // RE4
MusicalNote.fromNameAndOctave(4, 4), // MI4
MusicalNote.fromNameAndOctave(6, 4), // FA#4
MusicalNote.fromNameAndOctave(7, 4), // SOL4
),
),
),
Exercise(
id = 4,
title = "Arpeggi Maggiori",
description = "Salti di terza e quinta",
level = 4,
sequence = NoteSequence(
name = "Arpeggi Maggiori",
notes = listOf(
MusicalNote.fromNameAndOctave(0, 4), // DO4
MusicalNote.fromNameAndOctave(4, 4), // MI4
MusicalNote.fromNameAndOctave(7, 4), // SOL4
MusicalNote.fromNameAndOctave(0, 5), // DO5
MusicalNote.fromNameAndOctave(7, 3), // SOL3
MusicalNote.fromNameAndOctave(11, 3), // SI3
MusicalNote.fromNameAndOctave(2, 4), // RE4
MusicalNote.fromNameAndOctave(7, 4), // SOL4
),
),
),
Exercise(
id = 5,
title = "Ave Maria — Schubert",
description = "Melodia principale (Sib maggiore)",
level = 3,
sequence = NoteSequence(
name = "Ave Maria — Schubert",
notes = listOf(
MusicalNote.fromMidi(70), // Sib4
MusicalNote.fromMidi(69), // La4
MusicalNote.fromMidi(70), // Sib4
MusicalNote.fromMidi(74), // Re5
MusicalNote.fromMidi(72), // Do5
MusicalNote.fromMidi(70), // Sib4
MusicalNote.fromMidi(72), // Do5
MusicalNote.fromMidi(74), // Re5
MusicalNote.fromMidi(72), // Do5
MusicalNote.fromMidi(70), // Sib4
MusicalNote.fromMidi(69), // La4
MusicalNote.fromMidi(67), // Sol4
MusicalNote.fromMidi(69), // La4
MusicalNote.fromMidi(70), // Sib4
MusicalNote.fromMidi(74), // Re5
MusicalNote.fromMidi(74), // Re5
MusicalNote.fromMidi(72), // Do5
MusicalNote.fromMidi(70), // Sib4
MusicalNote.fromMidi(69), // La4
MusicalNote.fromMidi(67), // Sol4
MusicalNote.fromMidi(74), // Re5
MusicalNote.fromMidi(76), // Mi5
MusicalNote.fromMidi(74), // Re5
MusicalNote.fromMidi(73), // Do#5
MusicalNote.fromMidi(69), // La4
),
),
),
)
}
@@ -0,0 +1,148 @@
package com.notemusicali.goals
import com.notemusicali.scan.AppSettings
import com.notemusicali.util.currentDateString
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class DailyGoalSettings(
val targetMinutes: Int = 15,
)
@Serializable
data class DailyGoalState(
val streak: Int = 0,
val lastGoalMetDate: String = "",
val todayMs: Long = 0L,
val todayDate: String = "",
val history: Map<String, Long> = emptyMap(), // date -> ms practiced
)
object DailyGoalManager {
private const val SETTINGS_KEY = "daily_goal_settings"
private const val STATE_KEY = "daily_goal_state"
private val json = Json { ignoreUnknownKeys = true }
fun getSettings(): DailyGoalSettings {
val raw = AppSettings.getString(SETTINGS_KEY) ?: return DailyGoalSettings()
return try {
json.decodeFromString<DailyGoalSettings>(raw)
} catch (_: Exception) { DailyGoalSettings() }
}
fun saveSettings(settings: DailyGoalSettings) {
AppSettings.putString(SETTINGS_KEY, json.encodeToString(settings))
}
fun getState(): DailyGoalState {
val raw = AppSettings.getString(STATE_KEY) ?: return DailyGoalState()
return try {
val state = json.decodeFromString<DailyGoalState>(raw)
val today = currentDateString()
if (state.todayDate != today) {
// New day — reset today counter, keep history
state.copy(todayMs = 0L, todayDate = today)
} else state
} catch (_: Exception) { DailyGoalState() }
}
fun recordPractice(durationMs: Long) {
val today = currentDateString()
val settings = getSettings()
var state = getState()
// Ensure todayDate is current
if (state.todayDate != today) {
state = state.copy(todayMs = 0L, todayDate = today)
}
val newTodayMs = state.todayMs + durationMs
val updatedHistory = state.history.toMutableMap()
updatedHistory[today] = newTodayMs
// Keep only last 60 days of history
val sortedKeys = updatedHistory.keys.sorted()
if (sortedKeys.size > 60) {
sortedKeys.take(sortedKeys.size - 60).forEach { updatedHistory.remove(it) }
}
val targetMs = settings.targetMinutes * 60_000L
val goalMetToday = newTodayMs >= targetMs
val wasMetBefore = state.todayMs >= targetMs
var streak = state.streak
if (goalMetToday && !wasMetBefore) {
// Calculate streak: check if yesterday was the last goal-met date
val yesterday = previousDateString(today)
streak = if (state.lastGoalMetDate == yesterday || state.lastGoalMetDate == today) {
state.streak + 1
} else {
1
}
}
val newState = state.copy(
todayMs = newTodayMs,
todayDate = today,
streak = streak,
lastGoalMetDate = if (goalMetToday) today else state.lastGoalMetDate,
history = updatedHistory,
)
AppSettings.putString(STATE_KEY, json.encodeToString(newState))
}
fun isTodayGoalMet(): Boolean {
val settings = getSettings()
val state = getState()
val today = currentDateString()
if (state.todayDate != today) return false
return state.todayMs >= settings.targetMinutes * 60_000L
}
fun todayProgressFraction(): Float {
val settings = getSettings()
val state = getState()
val today = currentDateString()
if (state.todayDate != today) return 0f
val target = settings.targetMinutes * 60_000L
if (target <= 0) return 1f
return (state.todayMs.toFloat() / target).coerceAtMost(1f)
}
/** Get last N days of practice history (date -> minutes). */
fun recentHistory(days: Int = 30): List<Pair<String, Int>> {
val state = getState()
val allDates = state.history.entries
.sortedByDescending { it.key }
.take(days)
.reversed()
return allDates.map { (date, ms) -> date to (ms / 60_000L).toInt() }
}
private fun previousDateString(yyyymmdd: String): String {
if (yyyymmdd.length != 8) return ""
val y = yyyymmdd.substring(0, 4).toIntOrNull() ?: return ""
val m = yyyymmdd.substring(4, 6).toIntOrNull() ?: return ""
val d = yyyymmdd.substring(6, 8).toIntOrNull() ?: return ""
return if (d > 1) {
"$y${m.toString().padStart(2, '0')}${(d - 1).toString().padStart(2, '0')}"
} else if (m > 1) {
val prevMonth = m - 1
val daysInPrevMonth = daysInMonth(prevMonth, y)
"$y${prevMonth.toString().padStart(2, '0')}${daysInPrevMonth.toString().padStart(2, '0')}"
} else {
"${y - 1}1231"
}
}
private fun daysInMonth(month: Int, year: Int): Int = when (month) {
1 -> 31; 2 -> if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) 29 else 28
3 -> 31; 4 -> 30; 5 -> 31; 6 -> 30; 7 -> 31; 8 -> 31
9 -> 30; 10 -> 31; 11 -> 30; 12 -> 31; else -> 30
}
}
@@ -0,0 +1,35 @@
package com.notemusicali.history
import kotlinx.serialization.Serializable
@Serializable
data class NoteResult(
val noteName: String,
val midiNumber: Int,
val centsDeviation: Float,
val reactionTimeMs: Long,
val firstAttemptSuccess: Boolean,
)
@Serializable
data class PracticeSession(
val id: String,
val sequenceName: String,
val instrumentName: String,
val dateMillis: Long,
val totalTimeMs: Long,
val noteResults: List<NoteResult>,
val noteMidiNumbers: List<Int>,
val completed: Boolean = true,
) {
val averageCents: Float
get() = if (noteResults.isEmpty()) 0f
else noteResults.map { kotlin.math.abs(it.centsDeviation) }.average().toFloat()
val perfectNotes: Int
get() = noteResults.count { kotlin.math.abs(it.centsDeviation) <= 10f }
val firstAttemptRate: Float
get() = if (noteResults.isEmpty()) 0f
else noteResults.count { it.firstAttemptSuccess }.toFloat() / noteResults.size
}
@@ -0,0 +1,30 @@
package com.notemusicali.history
import com.notemusicali.scan.AppSettings
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object SessionRepository {
private const val KEY = "practice_sessions"
private const val MAX_SESSIONS = 50
private val json = Json { ignoreUnknownKeys = true }
fun load(): List<PracticeSession> {
val raw = AppSettings.getString(KEY) ?: return emptyList()
return try {
json.decodeFromString<List<PracticeSession>>(raw)
} catch (_: Exception) {
emptyList()
}
}
fun save(session: PracticeSession) {
val current = load().toMutableList()
current.add(0, session)
if (current.size > MAX_SESSIONS) {
current.subList(MAX_SESSIONS, current.size).clear()
}
AppSettings.putString(KEY, json.encodeToString(current))
}
}
@@ -0,0 +1,72 @@
package com.notemusicali.metronome
import com.notemusicali.audio.ToneGenerator
import com.notemusicali.audio.createToneGenerator
import com.notemusicali.util.currentTimeMillis
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
enum class TimeSignature(val displayName: String, val beatsPerMeasure: Int) {
TWO_FOUR("2/4", 2),
THREE_FOUR("3/4", 3),
FOUR_FOUR("4/4", 4),
SIX_EIGHT("6/8", 6),
}
class MetronomeEngine(
private val toneGenerator: ToneGenerator = createToneGenerator(),
private val nowMs: () -> Long = { currentTimeMillis() },
) {
private val _currentBeat = MutableStateFlow(0)
val currentBeat: StateFlow<Int> = _currentBeat.asStateFlow()
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow<Boolean> = _isPlaying.asStateFlow()
var bpm: Int = 120
var timeSignature: TimeSignature = TimeSignature.FOUR_FOUR
private var job: Job? = null
fun start(scope: CoroutineScope) {
if (_isPlaying.value) return
_isPlaying.value = true
_currentBeat.value = 0
job = scope.launch {
var beat = 0
// Absolute-time grid: each tick is scheduled from the accumulated ideal
// time, so click/scheduler latency never accumulates as drift.
var nextTickMs = nowMs().toDouble()
while (isActive) {
val isAccent = beat == 0
_currentBeat.value = beat
toneGenerator.playClick(accent = isAccent)
nextTickMs += 60_000.0 / bpm
delay((nextTickMs - nowMs()).toLong().coerceAtLeast(0L))
beat = (beat + 1) % timeSignature.beatsPerMeasure
}
}
}
fun stop() {
job?.cancel()
job = null
_isPlaying.value = false
_currentBeat.value = 0
toneGenerator.stop()
}
fun release() {
stop()
toneGenerator.release()
}
}
@@ -0,0 +1,65 @@
package com.notemusicali.music
/**
* Maps note indices (0-11) to Italian notation names.
* 0=DO(C), 1=DO#, 2=RE(D), ... 11=SI(B)
*/
object ItalianNotation {
data class NoteName(
val sharp: String,
val flat: String?,
) {
val display: String get() = sharp
}
private val NOTE_NAMES = arrayOf(
NoteName("DO", null), // 0 - C
NoteName("DO#", "REb"), // 1 - C#/Db
NoteName("RE", null), // 2 - D
NoteName("RE#", "MIb"), // 3 - D#/Eb
NoteName("MI", null), // 4 - E
NoteName("FA", null), // 5 - F
NoteName("FA#", "SOLb"), // 6 - F#/Gb
NoteName("SOL", null), // 7 - G
NoteName("SOL#", "LAb"), // 8 - G#/Ab
NoteName("LA", null), // 9 - A
NoteName("LA#", "SIb"), // 10 - A#/Bb
NoteName("SI", null), // 11 - B
)
fun fromNoteIndex(index: Int): NoteName {
return NOTE_NAMES[index % 12]
}
fun allNotes(): List<NoteName> = NOTE_NAMES.toList()
/**
* Parse an Italian note name to its index (0-11).
* Accepts both sharp and flat variants.
*/
fun toNoteIndex(name: String): Int? {
val upper = name.uppercase().trim()
return NOTE_NAMES.indexOfFirst { noteName ->
noteName.sharp.uppercase() == upper ||
noteName.flat?.uppercase() == upper
}.takeIf { it >= 0 }
}
/**
* Map MusicXML pitch step (C,D,E,F,G,A,B) + alter to note index.
*/
fun fromMusicXmlStep(step: String, alter: Int = 0): Int? {
val baseIndex = when (step.uppercase()) {
"C" -> 0
"D" -> 2
"E" -> 4
"F" -> 5
"G" -> 7
"A" -> 9
"B" -> 11
else -> return null
}
return ((baseIndex + alter) + 12) % 12
}
}
@@ -0,0 +1,106 @@
package com.notemusicali.music
/**
* Parses MusicXML content to extract a monophonic note sequence suitable for
* guided practice:
* - only the first <part> is read (typically the melody / right hand)
* - only the first voice of that part is kept
* - chord continuation notes (<chord/>), grace notes and tied-note
* continuations (<tie type="stop">) are skipped
* - rests are skipped
* - only the first time signature is used; mid-piece meter changes are ignored
*/
object MusicXmlParser {
// <part\s...> or <part>, but not <part-list>/<part-name>
private val PART_START_REGEX = Regex("""<part(\s[^>]*)?>""")
private val NOTE_BLOCK_REGEX = Regex("""<note\b[^>]*>(.*?)</note>""", RegexOption.DOT_MATCHES_ALL)
private val REST_REGEX = Regex("""<rest\b""")
private val CHORD_REGEX = Regex("""<chord\b""")
private val GRACE_REGEX = Regex("""<grace\b""")
private val TIE_STOP_REGEX = Regex("""<tie\b[^>]*type\s*=\s*["']stop["']""")
private val VOICE_REGEX = Regex("""<voice>\s*(\d+)\s*</voice>""")
private val DIVISIONS_REGEX = Regex("""<divisions>\s*(\d+)\s*</divisions>""")
private val DURATION_REGEX = Regex("""<duration>\s*(\d+)\s*</duration>""")
private val STEP_REGEX = Regex("""<step>([A-Ga-g])</step>""")
private val ALTER_REGEX = Regex("""<alter>(-?\d+)</alter>""")
private val OCTAVE_REGEX = Regex("""<octave>(\d+)</octave>""")
private val TYPE_REGEX = Regex("""<type>(\w+)</type>""")
private val TIME_REGEX = Regex(
"""<time(?:\s[^>]*)?>.*?<beats>\s*(\d+)\s*</beats>.*?<beat-type>\s*(\d+)\s*</beat-type>.*?</time>""",
RegexOption.DOT_MATCHES_ALL,
)
fun parse(xmlContent: String, name: String = "MusicXML"): NoteSequence {
val partContent = firstPartContent(xmlContent)
val notes = mutableListOf<MusicalNote>()
var firstVoice: String? = null
// <divisions> (in <attributes>) defines how many duration units make a
// quarter note; it can change mid-part, so track each occurrence's position
val divisionsByOffset = DIVISIONS_REGEX.findAll(partContent)
.mapNotNull { m -> m.groupValues[1].toIntOrNull()?.let { m.range.first to it } }
.toList()
NOTE_BLOCK_REGEX.findAll(partContent).forEach { noteMatch ->
val noteBlock = noteMatch.groupValues[1]
if (REST_REGEX.containsMatchIn(noteBlock)) return@forEach
if (CHORD_REGEX.containsMatchIn(noteBlock)) return@forEach
if (GRACE_REGEX.containsMatchIn(noteBlock)) return@forEach
if (TIE_STOP_REGEX.containsMatchIn(noteBlock)) return@forEach
val voice = VOICE_REGEX.find(noteBlock)?.groupValues?.get(1)
if (voice != null) {
if (firstVoice == null) firstVoice = voice
else if (voice != firstVoice) return@forEach
}
val step = STEP_REGEX.find(noteBlock)?.groupValues?.get(1) ?: return@forEach
val alter = ALTER_REGEX.find(noteBlock)?.groupValues?.get(1)?.toIntOrNull() ?: 0
val octave = OCTAVE_REGEX.find(noteBlock)?.groupValues?.get(1)?.toIntOrNull() ?: 4
val noteIndex = ItalianNotation.fromMusicXmlStep(step.uppercase(), alter) ?: return@forEach
val type = TYPE_REGEX.find(noteBlock)?.groupValues?.get(1)
val duration = when {
type != null -> NoteDuration.fromMusicXml(type)
else -> durationFromRatio(noteBlock, noteMatch.range.first, divisionsByOffset)
}
notes.add(MusicalNote.fromNameAndOctave(noteIndex, octave, duration, preferFlat = alter < 0))
}
val time = TIME_REGEX.find(partContent)
val beats = time?.groupValues?.get(1)?.toIntOrNull() ?: 4
val beatType = time?.groupValues?.get(2)?.toIntOrNull() ?: 4
return NoteSequence(name = name, notes = notes, beats = beats, beatType = beatType)
}
/** Derives the note value from <duration>/<divisions> when <type> is absent. */
private fun durationFromRatio(
noteBlock: String,
noteOffset: Int,
divisionsByOffset: List<Pair<Int, Int>>,
): NoteDuration {
val durationUnits = DURATION_REGEX.find(noteBlock)?.groupValues?.get(1)?.toIntOrNull()
?: return NoteDuration.QUARTER
val divisions = divisionsByOffset.lastOrNull { it.first < noteOffset }?.second ?: 1
if (divisions <= 0) return NoteDuration.QUARTER
val quarters = durationUnits.toDouble() / divisions
return when {
quarters >= 4.0 -> NoteDuration.WHOLE
quarters >= 2.0 -> NoteDuration.HALF
quarters >= 1.0 -> NoteDuration.QUARTER
quarters >= 0.5 -> NoteDuration.EIGHTH
quarters >= 0.25 -> NoteDuration.SIXTEENTH
else -> NoteDuration.THIRTY_SECOND
}
}
/** Content of the first <part> element, or the whole document if none is found. */
private fun firstPartContent(xml: String): String {
val start = PART_START_REGEX.find(xml) ?: return xml
val contentStart = start.range.last + 1
val end = xml.indexOf("</part>", contentStart)
return if (end >= 0) xml.substring(contentStart, end) else xml.substring(contentStart)
}
}
@@ -0,0 +1,76 @@
package com.notemusicali.music
enum class NoteDuration(val holdMs: Long) {
WHOLE(2500),
HALF(1500),
QUARTER(1000),
EIGHTH(600),
SIXTEENTH(400),
THIRTY_SECOND(300),
;
companion object {
fun fromMusicXml(type: String): NoteDuration = when (type) {
"whole" -> WHOLE
"half" -> HALF
"quarter" -> QUARTER
"eighth" -> EIGHTH
"16th" -> SIXTEENTH
"32nd" -> THIRTY_SECOND
else -> QUARTER
}
}
}
/**
* Represents a musical note with its properties.
*/
data class MusicalNote(
val noteIndex: Int,
val octave: Int,
val italianName: ItalianNotation.NoteName,
val midiNumber: Int,
val duration: NoteDuration = NoteDuration.QUARTER,
// Enharmonic preference: a black key can be spelled as sharp (default) or flat
val preferFlat: Boolean = false,
) {
val displayName: String
get() = if (preferFlat) italianName.flat ?: italianName.sharp else italianName.sharp
val fullName: String get() = "$displayName$octave"
companion object {
fun fromMidi(
midiNumber: Int,
duration: NoteDuration = NoteDuration.QUARTER,
preferFlat: Boolean = false,
): MusicalNote {
val noteIndex = ((midiNumber % 12) + 12) % 12
val octave = (midiNumber / 12) - 1
return MusicalNote(
noteIndex = noteIndex,
octave = octave,
italianName = ItalianNotation.fromNoteIndex(noteIndex),
midiNumber = midiNumber,
duration = duration,
preferFlat = preferFlat,
)
}
fun fromNameAndOctave(
noteIndex: Int,
octave: Int,
duration: NoteDuration = NoteDuration.QUARTER,
preferFlat: Boolean = false,
): MusicalNote {
val midiNumber = (octave + 1) * 12 + noteIndex
return MusicalNote(
noteIndex = noteIndex,
octave = octave,
italianName = ItalianNotation.fromNoteIndex(noteIndex),
midiNumber = midiNumber,
duration = duration,
preferFlat = preferFlat,
)
}
}
}
@@ -0,0 +1,21 @@
package com.notemusicali.music
/**
* Ordered list of notes for practice mode.
*/
data class NoteSequence(
val name: String,
val notes: List<MusicalNote>,
val beats: Int = 4,
val beatType: Int = 4,
) {
val size: Int get() = notes.size
fun noteAt(index: Int): MusicalNote? = notes.getOrNull(index)
companion object {
fun empty(name: String = "Nuova Sequenza"): NoteSequence {
return NoteSequence(name = name, notes = emptyList())
}
}
}
@@ -0,0 +1,76 @@
package com.notemusicali.music
enum class ScaleType(val displayName: String, val intervals: List<Int>) {
MAJOR("Maggiore", listOf(0, 2, 4, 5, 7, 9, 11, 12)),
NATURAL_MINOR("Minore Naturale", listOf(0, 2, 3, 5, 7, 8, 10, 12)),
HARMONIC_MINOR("Minore Armonica", listOf(0, 2, 3, 5, 7, 8, 11, 12)),
MELODIC_MINOR("Minore Melodica", listOf(0, 2, 3, 5, 7, 9, 11, 12)),
PENTATONIC_MAJOR("Pentatonica Maggiore", listOf(0, 2, 4, 7, 9, 12)),
PENTATONIC_MINOR("Pentatonica Minore", listOf(0, 3, 5, 7, 10, 12)),
CHROMATIC("Cromatica", listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)),
}
enum class ArpeggioType(val displayName: String, val intervals: List<Int>) {
MAJOR("Maggiore", listOf(0, 4, 7, 12)),
MINOR("Minore", listOf(0, 3, 7, 12)),
DIMINISHED("Diminuito", listOf(0, 3, 6, 12)),
AUGMENTED("Aumentato", listOf(0, 4, 8, 12)),
DOMINANT_7TH("Settima Dom.", listOf(0, 4, 7, 10, 12)),
MAJOR_7TH("Settima Magg.", listOf(0, 4, 7, 11, 12)),
MINOR_7TH("Settima Min.", listOf(0, 3, 7, 10, 12)),
}
enum class ScaleDirection(val displayName: String) {
ASCENDING("Ascendente"),
DESCENDING("Discendente"),
BOTH("Asc. + Disc."),
}
object ScaleGenerator {
fun generateScale(
rootNoteIndex: Int,
octave: Int,
scaleType: ScaleType,
direction: ScaleDirection = ScaleDirection.ASCENDING,
): NoteSequence {
val rootMidi = (octave + 1) * 12 + rootNoteIndex
val rootName = ItalianNotation.fromNoteIndex(rootNoteIndex).display
val name = "Scala $rootName ${scaleType.displayName}"
val ascending = scaleType.intervals.map { interval ->
MusicalNote.fromMidi(rootMidi + interval)
}
val notes = when (direction) {
ScaleDirection.ASCENDING -> ascending
ScaleDirection.DESCENDING -> ascending.reversed()
ScaleDirection.BOTH -> ascending + ascending.dropLast(1).reversed()
}
return NoteSequence(name = name, notes = notes)
}
fun generateArpeggio(
rootNoteIndex: Int,
octave: Int,
arpeggioType: ArpeggioType,
direction: ScaleDirection = ScaleDirection.ASCENDING,
): NoteSequence {
val rootMidi = (octave + 1) * 12 + rootNoteIndex
val rootName = ItalianNotation.fromNoteIndex(rootNoteIndex).display
val name = "Arpeggio $rootName ${arpeggioType.displayName}"
val ascending = arpeggioType.intervals.map { interval ->
MusicalNote.fromMidi(rootMidi + interval)
}
val notes = when (direction) {
ScaleDirection.ASCENDING -> ascending
ScaleDirection.DESCENDING -> ascending.reversed()
ScaleDirection.BOTH -> ascending + ascending.dropLast(1).reversed()
}
return NoteSequence(name = name, notes = notes)
}
}
@@ -0,0 +1,267 @@
package com.notemusicali.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.notemusicali.audio.InstrumentPreset
import com.notemusicali.challenge.ChallengeTimeLimit
import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteSequence
import com.notemusicali.ui.challenge.ChallengeScreen
import com.notemusicali.ui.challenge.ChallengeSetupScreen
import com.notemusicali.ui.challenge.ChallengeViewModel
import com.notemusicali.ui.challenge.LeaderboardScreen
import com.notemusicali.ui.eartraining.EarTrainingScreen
import com.notemusicali.ui.exercises.ExercisesScreen
import com.notemusicali.ui.goals.DailyGoalScreen
import com.notemusicali.ui.guide.GuideScreen
import com.notemusicali.ui.history.HistoryScreen
import com.notemusicali.ui.home.HomeScreen
import com.notemusicali.ui.metronome.MetronomeScreen
import com.notemusicali.ui.practice.ManualInputScreen
import com.notemusicali.ui.practice.PracticeScreen
import com.notemusicali.ui.practice.PracticeSetupScreen
import com.notemusicali.ui.practice.PracticeViewModel
import com.notemusicali.ui.reference.ReferenceNoteScreen
import com.notemusicali.ui.scales.ScaleGeneratorScreen
import com.notemusicali.ui.scan.ScanScreen
import com.notemusicali.ui.scan.ScanViewModel
import com.notemusicali.ui.scores.ScoresScreen
import com.notemusicali.ui.stats.StatsScreen
import com.notemusicali.ui.tuner.TunerScreen
object Routes {
const val HOME = "home"
const val TUNER = "tuner"
const val PRACTICE_SETUP = "practice_setup"
const val MANUAL_INPUT = "manual_input"
const val SCAN = "scan"
const val PRACTICE = "practice"
const val EXERCISES = "exercises"
const val HISTORY = "history"
const val SCORES = "scores"
const val METRONOME = "metronome"
const val REFERENCE_NOTE = "reference_note"
const val SCALES = "scales"
const val EAR_TRAINING = "ear_training"
const val CHALLENGE_SETUP = "challenge_setup"
const val CHALLENGE = "challenge"
const val LEADERBOARD = "leaderboard"
const val STATS = "stats"
const val DAILY_GOALS = "daily_goals"
const val GUIDE = "guide"
}
@Composable
fun AppNavigation() {
val navController = rememberNavController()
var currentSequence by remember { mutableStateOf<NoteSequence?>(null) }
var currentInstrument by remember { mutableStateOf(InstrumentPreset.VIOLINO) }
var challengeTimeLimit by remember { mutableStateOf(ChallengeTimeLimit.MEDIUM) }
NavHost(
navController = navController,
startDestination = Routes.HOME,
) {
composable(Routes.HOME) {
HomeScreen(
onNavigateToTuner = { navController.navigate(Routes.TUNER) },
onNavigateToPractice = { navController.navigate(Routes.PRACTICE_SETUP) },
onNavigateToExercises = { navController.navigate(Routes.EXERCISES) },
onNavigateToHistory = { navController.navigate(Routes.HISTORY) },
onNavigateToScores = { navController.navigate(Routes.SCORES) },
onNavigateToMetronome = { navController.navigate(Routes.METRONOME) },
onNavigateToReference = { navController.navigate(Routes.REFERENCE_NOTE) },
onNavigateToScales = { navController.navigate(Routes.SCALES) },
onNavigateToEarTraining = { navController.navigate(Routes.EAR_TRAINING) },
onNavigateToChallenge = { navController.navigate(Routes.CHALLENGE_SETUP) },
onNavigateToLeaderboard = { navController.navigate(Routes.LEADERBOARD) },
onNavigateToStats = { navController.navigate(Routes.STATS) },
onNavigateToDailyGoals = { navController.navigate(Routes.DAILY_GOALS) },
onNavigateToGuide = { navController.navigate(Routes.GUIDE) },
)
}
composable(Routes.TUNER) {
TunerScreen(onBack = { navController.popBackStack() })
}
composable(Routes.METRONOME) {
MetronomeScreen(onBack = { navController.popBackStack() })
}
composable(Routes.REFERENCE_NOTE) {
ReferenceNoteScreen(onBack = { navController.popBackStack() })
}
composable(Routes.SCALES) {
ScaleGeneratorScreen(
onBack = { navController.popBackStack() },
onPractice = { sequence ->
currentSequence = sequence
currentInstrument = InstrumentPreset.VIOLINO
navController.navigate(Routes.PRACTICE) {
popUpTo(Routes.SCALES)
}
},
)
}
composable(Routes.EAR_TRAINING) {
EarTrainingScreen(onBack = { navController.popBackStack() })
}
composable(Routes.CHALLENGE_SETUP) {
ChallengeSetupScreen(
onBack = { navController.popBackStack() },
onStart = { sequence, timeLimit ->
currentSequence = sequence
challengeTimeLimit = timeLimit
navController.navigate(Routes.CHALLENGE) {
popUpTo(Routes.CHALLENGE_SETUP)
}
},
)
}
composable(Routes.CHALLENGE) {
val challengeViewModel: ChallengeViewModel = viewModel { ChallengeViewModel() }
LaunchedEffect(currentSequence, challengeTimeLimit) {
currentSequence?.let { seq ->
challengeViewModel.setSequence(seq)
challengeViewModel.setTimeLimit(challengeTimeLimit)
challengeViewModel.start()
}
}
ChallengeScreen(
onBack = { navController.popBackStack(Routes.HOME, inclusive = false) },
viewModel = challengeViewModel,
)
}
composable(Routes.LEADERBOARD) {
LeaderboardScreen(onBack = { navController.popBackStack() })
}
composable(Routes.STATS) {
StatsScreen(onBack = { navController.popBackStack() })
}
composable(Routes.DAILY_GOALS) {
DailyGoalScreen(onBack = { navController.popBackStack() })
}
composable(Routes.GUIDE) {
GuideScreen(onBack = { navController.popBackStack() })
}
composable(Routes.PRACTICE_SETUP) {
PracticeSetupScreen(
onBack = { navController.popBackStack() },
onManualInput = { navController.navigate(Routes.MANUAL_INPUT) },
onScan = { navController.navigate(Routes.SCAN) },
onMusicXmlLoaded = { sequence ->
currentSequence = sequence
navController.navigate(Routes.PRACTICE)
},
)
}
composable(Routes.MANUAL_INPUT) {
ManualInputScreen(
onBack = { navController.popBackStack() },
onSequenceReady = { sequence, instrument ->
currentSequence = sequence
currentInstrument = instrument
navController.navigate(Routes.PRACTICE)
},
)
}
composable(Routes.SCAN) {
val scanViewModel: ScanViewModel = viewModel { ScanViewModel() }
ScanScreen(
onBack = { navController.popBackStack() },
onSequenceCaptured = { sequence, instrument ->
currentSequence = sequence
currentInstrument = instrument
navController.navigate(Routes.PRACTICE) {
popUpTo(Routes.PRACTICE_SETUP)
}
},
viewModel = scanViewModel,
)
}
composable(Routes.EXERCISES) {
ExercisesScreen(
onBack = { navController.popBackStack() },
onExerciseSelected = { sequence ->
currentSequence = sequence
currentInstrument = InstrumentPreset.VIOLINO
navController.navigate(Routes.PRACTICE) {
popUpTo(Routes.EXERCISES)
}
},
)
}
composable(Routes.SCORES) {
ScoresScreen(
onBack = { navController.popBackStack() },
onScoreSelected = { sequence ->
currentSequence = sequence
currentInstrument = InstrumentPreset.PIANOFORTE
navController.navigate(Routes.PRACTICE) {
popUpTo(Routes.SCORES)
}
},
)
}
composable(Routes.HISTORY) {
HistoryScreen(
onBack = { navController.popBackStack() },
onReplay = { session ->
val notes = session.noteMidiNumbers.map { MusicalNote.fromMidi(it) }
currentSequence = NoteSequence(
name = session.sequenceName,
notes = notes,
)
currentInstrument = InstrumentPreset.entries.firstOrNull {
it.displayName == session.instrumentName
} ?: InstrumentPreset.VIOLINO
navController.navigate(Routes.PRACTICE) {
popUpTo(Routes.HISTORY)
}
},
)
}
composable(Routes.PRACTICE) {
val practiceViewModel: PracticeViewModel = viewModel { PracticeViewModel() }
LaunchedEffect(currentSequence, currentInstrument) {
practiceViewModel.selectInstrument(currentInstrument)
currentSequence?.let { seq ->
practiceViewModel.setSequence(seq)
}
practiceViewModel.startListening()
}
PracticeScreen(
onBack = { navController.popBackStack() },
viewModel = practiceViewModel,
onFinished = {
navController.popBackStack(Routes.HOME, inclusive = false)
},
)
}
}
}
@@ -0,0 +1,76 @@
package com.notemusicali.scan
import com.russhwolf.settings.Settings
/**
* Platform-backed secure key-value store (iOS Keychain, Android
* EncryptedSharedPreferences). Null when the platform has none — values then
* stay in the regular store with obfuscation.
*/
internal expect fun createSecureSettings(): Settings?
/**
* Routes sensitive keys to the secure store when one exists, transparently
* migrating legacy values out of the plain store. Everything else stays in
* the plain store (obfuscated, to avoid trivial plaintext inspection).
*/
internal class ApiKeyStore(
private val plain: Settings,
private val secure: Settings?,
) {
private val secureKeys = setOf("anthropic_api_key", "openai_api_key")
private val obfuscationKey = 0x4D // 'M' for music
private val hexChars = "0123456789abcdef"
fun getString(key: String): String? {
if (key in secureKeys && secure != null) {
secure.getStringOrNull(key)?.let { return it }
// One-time migration of a value stored before the secure store existed
val legacy = plain.getStringOrNull(key)?.let(::deobfuscate) ?: return null
secure.putString(key, legacy)
plain.remove(key)
return legacy
}
return plain.getStringOrNull(key)?.let(::deobfuscate)
}
fun putString(key: String, value: String) {
if (key in secureKeys && secure != null) {
secure.putString(key, value)
plain.remove(key)
} else {
plain.putString(key, obfuscate(value))
}
}
fun migratePlaintextIfNeeded(keys: List<String>) {
for (key in keys) {
val raw = plain.getStringOrNull(key) ?: continue
if (!raw.startsWith("OBF:")) {
plain.putString(key, obfuscate(raw))
}
}
}
private fun obfuscate(value: String): String {
val bytes = value.encodeToByteArray()
val sb = StringBuilder("OBF:")
for (b in bytes) {
val xored = (b.toInt() xor obfuscationKey) and 0xFF
sb.append(hexChars[xored shr 4])
sb.append(hexChars[xored and 0x0F])
}
return sb.toString()
}
private fun deobfuscate(stored: String): String {
if (!stored.startsWith("OBF:")) return stored // legacy plaintext
val hex = stored.removePrefix("OBF:")
val bytes = ByteArray(hex.length / 2) { i ->
val hi = hex[i * 2].digitToInt(16)
val lo = hex[i * 2 + 1].digitToInt(16)
((hi shl 4 or lo) xor obfuscationKey).toByte()
}
return bytes.decodeToString()
}
}
@@ -0,0 +1,27 @@
package com.notemusicali.scan
import com.russhwolf.settings.Settings
/**
* Cross-platform settings accessor. API keys go to the platform secure store
* when available (iOS Keychain, Android EncryptedSharedPreferences); other
* values use multiplatform-settings with basic obfuscation for sensitive-ish
* content.
*/
object AppSettings {
private val store by lazy { ApiKeyStore(Settings(), createSecureSettings()) }
fun getString(key: String): String? = store.getString(key)
fun putString(key: String, value: String) = store.putString(key, value)
/**
* Migrate any existing plaintext values to obfuscated format.
* Call once at app startup.
*/
fun migrateIfNeeded() {
store.migratePlaintextIfNeeded(
listOf("api_key", "omr_api_key", "claude_api_key", "openai_api_key", "anthropic_api_key"),
)
}
}
@@ -0,0 +1,123 @@
package com.notemusicali.scan
import com.notemusicali.music.MusicalNote
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
object ClaudeOmrProcessor : OmrProcessor {
private const val API_URL = "https://api.anthropic.com/v1/messages"
private val PROMPT = """
Analizza questa immagine di spartito musicale.
Elenca SOLO le note in ordine di apparizione, una per riga, nel formato:
LETTERA OTTAVA
Usa la notazione anglosassone: C D E F G A B (con # o b per alterazioni).
Esempio:
C4
D#5
Bb3
E4
Ignora pause, dinamiche, legature e qualsiasi altro simbolo non-nota.
Se non riesci a identificare note, rispondi esattamente: NESSUNA_NOTA
""".trimIndent()
private val json = Json { ignoreUnknownKeys = true }
override suspend fun analyze(imageBase64: String, apiKey: String): Result<List<MusicalNote>> {
return try {
val client = OmrHttp.createClient()
try {
val requestBody = buildRequestBody(imageBase64)
val response = client.post(API_URL) {
header("x-api-key", apiKey)
header("anthropic-version", "2023-06-01")
contentType(ContentType.Application.Json)
setBody(requestBody.toString())
}
val responseBody = response.bodyAsText()
if (response.status.value !in 200..299) {
return Result.failure(Exception(OmrErrorParsing.errorMessage(responseBody, response.status.value)))
}
parseResponse(responseBody)
} finally {
client.close()
}
} catch (e: Exception) {
Result.failure(e)
}
}
internal fun parseResponse(responseBody: String): Result<List<MusicalNote>> {
val content = try {
val responseJson = json.parseToJsonElement(responseBody).jsonObject
val contentArray = responseJson["content"]?.jsonArray
?: return Result.failure(Exception("Nessun contenuto nella risposta"))
contentArray
.map { it.jsonObject }
.firstOrNull { it["type"]?.jsonPrimitive?.content == "text" }
?.get("text")?.jsonPrimitive?.content?.trim()
?: return Result.failure(Exception("Nessun testo nella risposta"))
} catch (e: Exception) {
return Result.failure(Exception("Risposta non valida dal server"))
}
if (content == "NESSUNA_NOTA") {
return Result.failure(Exception("Nessuna nota riconosciuta nell'immagine"))
}
val notes = NoteParser.parseNotes(content)
return if (notes.isEmpty()) {
Result.failure(Exception("Nessuna nota valida trovata nella risposta"))
} else {
Result.success(notes)
}
}
private fun buildRequestBody(base64Image: String): JsonObject {
return buildJsonObject {
// claude-sonnet-4-20250514 is deprecated (retires June 2026); claude-sonnet-5 is its replacement
put("model", "claude-sonnet-5")
put("max_tokens", 1024)
put("messages", buildJsonArray {
add(buildJsonObject {
put("role", "user")
put("content", buildJsonArray {
add(buildJsonObject {
put("type", "image")
put("source", buildJsonObject {
put("type", "base64")
put("media_type", "image/jpeg")
put("data", base64Image)
})
})
add(buildJsonObject {
put("type", "text")
put("text", PROMPT)
})
})
})
})
}
}
}
@@ -0,0 +1,32 @@
package com.notemusicali.scan
import com.notemusicali.music.ItalianNotation
import com.notemusicali.music.MusicalNote
object NoteParser {
private val NOTE_REGEX = Regex("""^([A-Ga-g][#b]?)(\d)$""")
fun parseNotes(content: String): List<MusicalNote> {
return content.lines()
.map { it.trim() }
.filter { it.isNotEmpty() }
.mapNotNull { line ->
val match = NOTE_REGEX.matchEntire(line) ?: return@mapNotNull null
val noteName = match.groupValues[1]
val octave = match.groupValues[2].toIntOrNull() ?: return@mapNotNull null
val step = noteName.first().uppercase()
val alter = when {
noteName.length > 1 && noteName[1] == '#' -> 1
noteName.length > 1 && noteName[1] == 'b' -> -1
else -> 0
}
val noteIndex = ItalianNotation.fromMusicXmlStep(step, alter)
?: return@mapNotNull null
MusicalNote.fromNameAndOctave(noteIndex, octave, preferFlat = alter < 0)
}
}
}
@@ -0,0 +1,32 @@
package com.notemusicali.scan
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** Shared HTTP plumbing for the OMR providers. */
internal object OmrHttp {
fun createClient(): HttpClient = HttpClient {
install(HttpTimeout) {
// Vision requests on a large image can be slow, but must not hang forever
requestTimeoutMillis = 90_000
connectTimeoutMillis = 15_000
socketTimeoutMillis = 90_000
}
}
}
/** Extracts a human-readable message from a provider error body (Anthropic and OpenAI share the shape). */
object OmrErrorParsing {
private val json = Json { ignoreUnknownKeys = true }
fun errorMessage(body: String, statusCode: Int): String = try {
json.parseToJsonElement(body).jsonObject["error"]
?.jsonObject?.get("message")?.jsonPrimitive?.content
?: "Errore HTTP $statusCode"
} catch (_: Exception) {
"Errore HTTP $statusCode"
}
}
@@ -0,0 +1,10 @@
package com.notemusicali.scan
import com.notemusicali.music.MusicalNote
/**
* Interface for Optical Music Recognition processors.
*/
interface OmrProcessor {
suspend fun analyze(imageBase64: String, apiKey: String): Result<List<MusicalNote>>
}
@@ -0,0 +1,113 @@
package com.notemusicali.scan
import com.notemusicali.music.MusicalNote
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
object OpenAiOmrProcessor : OmrProcessor {
private const val API_URL = "https://api.openai.com/v1/chat/completions"
private val PROMPT = """
Analizza questa immagine di spartito musicale.
Elenca SOLO le note in ordine di apparizione, una per riga, nel formato:
LETTERA OTTAVA
Usa la notazione anglosassone: C D E F G A B (con # o b per alterazioni).
Esempio:
C4
D#5
Bb3
E4
Ignora pause, dinamiche, legature e qualsiasi altro simbolo non-nota.
Se non riesci a identificare note, rispondi esattamente: NESSUNA_NOTA
""".trimIndent()
private val json = Json { ignoreUnknownKeys = true }
override suspend fun analyze(imageBase64: String, apiKey: String): Result<List<MusicalNote>> {
return try {
val client = OmrHttp.createClient()
try {
val requestBody = buildRequestBody(imageBase64)
val response = client.post(API_URL) {
header("Authorization", "Bearer $apiKey")
contentType(ContentType.Application.Json)
setBody(requestBody.toString())
}
val responseBody = response.bodyAsText()
if (response.status.value !in 200..299) {
return Result.failure(Exception(OmrErrorParsing.errorMessage(responseBody, response.status.value)))
}
parseResponse(responseBody)
} finally {
client.close()
}
} catch (e: Exception) {
Result.failure(e)
}
}
internal fun parseResponse(responseBody: String): Result<List<MusicalNote>> {
val content = try {
json.parseToJsonElement(responseBody).jsonObject
.let { it["choices"]?.jsonArray }
?.firstOrNull()?.jsonObject
?.get("message")?.jsonObject
?.get("content")?.jsonPrimitive?.content?.trim()
?: return Result.failure(Exception("Risposta vuota dal server"))
} catch (e: Exception) {
return Result.failure(Exception("Risposta non valida dal server"))
}
if (content == "NESSUNA_NOTA") {
return Result.failure(Exception("Nessuna nota riconosciuta nell'immagine"))
}
val notes = NoteParser.parseNotes(content)
return if (notes.isEmpty()) {
Result.failure(Exception("Nessuna nota valida trovata nella risposta"))
} else {
Result.success(notes)
}
}
private fun buildRequestBody(base64Image: String) = buildJsonObject {
put("model", "gpt-4o")
put("max_tokens", 1000)
put("temperature", 0.1)
put("messages", buildJsonArray {
add(buildJsonObject {
put("role", "user")
put("content", buildJsonArray {
add(buildJsonObject {
put("type", "text")
put("text", PROMPT)
})
add(buildJsonObject {
put("type", "image_url")
put("image_url", buildJsonObject {
put("url", "data:image/jpeg;base64,$base64Image")
put("detail", "high")
})
})
})
})
})
}
}
@@ -0,0 +1,11 @@
package com.notemusicali.scan
/**
* Platform-agnostic wrapper for image data.
*/
expect class PlatformImage
/**
* Encode a platform image to Base64 JPEG string.
*/
expect fun PlatformImage.toBase64Jpeg(maxDim: Int = 1024, quality: Int = 85): String
@@ -0,0 +1,121 @@
package com.notemusicali.stats
import com.notemusicali.history.PracticeSession
import com.notemusicali.history.SessionRepository
import kotlin.math.abs
data class DailyStats(
val dateLabel: String,
val sessionCount: Int,
val totalTimeMs: Long,
val averageCents: Float,
val firstAttemptRate: Float,
val averageReactionMs: Long,
)
data class OverallStats(
val totalSessions: Int,
val totalPracticeTimeMs: Long,
val bestAccuracyCents: Float,
val averageCents: Float,
val averageFirstAttemptRate: Float,
val completedSessions: Int,
)
object StatsCalculator {
fun calculateOverall(sessions: List<PracticeSession> = SessionRepository.load()): OverallStats {
if (sessions.isEmpty()) return OverallStats(0, 0L, 0f, 0f, 0f, 0)
val totalTime = sessions.sumOf { it.totalTimeMs }
val withResults = sessions.filter { it.noteResults.isNotEmpty() }
val avgCents = if (withResults.isEmpty()) 0f
else withResults.map { it.averageCents }.average().toFloat()
val bestCents = if (withResults.isEmpty()) 0f
else withResults.minOf { it.averageCents }
val avgFirstAttempt = if (withResults.isEmpty()) 0f
else withResults.map { it.firstAttemptRate }.average().toFloat()
return OverallStats(
totalSessions = sessions.size,
totalPracticeTimeMs = totalTime,
bestAccuracyCents = bestCents,
averageCents = avgCents,
averageFirstAttemptRate = avgFirstAttempt,
completedSessions = sessions.count { it.completed },
)
}
fun calculateDaily(sessions: List<PracticeSession> = SessionRepository.load()): List<DailyStats> {
if (sessions.isEmpty()) return emptyList()
// Group by day (using dateMillis / MS_PER_DAY)
val grouped = sessions.groupBy { it.dateMillis / 86_400_000L }
return grouped.entries
.sortedBy { it.key }
.map { (dayKey, daySessions) ->
val withResults = daySessions.filter { it.noteResults.isNotEmpty() }
val avgCents = if (withResults.isEmpty()) 0f
else withResults.map { it.averageCents }.average().toFloat()
val avgFirstAttempt = if (withResults.isEmpty()) 0f
else withResults.map { it.firstAttemptRate }.average().toFloat()
val avgReaction = if (withResults.isEmpty()) 0L
else withResults.flatMap { it.noteResults }
.map { it.reactionTimeMs }
.average().toLong()
// Simple date label from epoch day
val totalDays = dayKey
val year = estimateYear(totalDays)
val dayOfYear = estimateDayOfYear(totalDays, year)
val (month, day) = dayOfYearToMonthDay(dayOfYear, isLeapYear(year))
val dateLabel = "${day.toString().padStart(2, '0')}/${month.toString().padStart(2, '0')}"
DailyStats(
dateLabel = dateLabel,
sessionCount = daySessions.size,
totalTimeMs = daySessions.sumOf { it.totalTimeMs },
averageCents = avgCents,
firstAttemptRate = avgFirstAttempt,
averageReactionMs = avgReaction,
)
}
}
// Simple epoch day → year/month/day helpers (no platform dependency)
private fun isLeapYear(year: Int): Boolean =
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
private fun estimateYear(epochDay: Long): Int {
var y = 1970
var remaining = epochDay
while (true) {
val daysInYear = if (isLeapYear(y)) 366L else 365L
if (remaining < daysInYear) return y
remaining -= daysInYear
y++
}
}
private fun estimateDayOfYear(epochDay: Long, year: Int): Int {
var remaining = epochDay
var y = 1970
while (y < year) {
remaining -= if (isLeapYear(y)) 366L else 365L
y++
}
return remaining.toInt() + 1
}
private fun dayOfYearToMonthDay(dayOfYear: Int, leap: Boolean): Pair<Int, Int> {
val daysInMonths = if (leap) intArrayOf(31,29,31,30,31,30,31,31,30,31,30,31)
else intArrayOf(31,28,31,30,31,30,31,31,30,31,30,31)
var remaining = dayOfYear
for (m in daysInMonths.indices) {
if (remaining <= daysInMonths[m]) return Pair(m + 1, remaining)
remaining -= daysInMonths[m]
}
return Pair(12, 31)
}
}
@@ -0,0 +1,292 @@
package com.notemusicali.ui.challenge
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.ui.components.KeepScreenOn
import com.notemusicali.ui.components.StaffView
import com.notemusicali.ui.theme.TunerColors
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun ChallengeScreen(
onBack: () -> Unit,
viewModel: ChallengeViewModel,
) {
val state by viewModel.uiState.collectAsState()
KeepScreenOn()
DisposableEffect(Unit) {
onDispose { /* cleanup handled in ViewModel */ }
}
if (state.isComplete) {
ChallengeCompleteView(
state = state,
onBack = onBack,
)
return
}
val backgroundColor by animateColorAsState(
targetValue = if (state.isCorrect) TunerColors.inTuneBackground
else TunerColors.silenceBackground,
animationSpec = tween(200),
label = "bgColor",
)
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor),
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier.widthIn(max = 600.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BackTopBar(title = stringResource(Res.string.challenge_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Timer bar
val timeProgress = state.remainingMs.toFloat() / (state.timeLimit.seconds * 1000f)
LinearProgressIndicator(
progress = { timeProgress },
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
color = when {
timeProgress > 0.5f -> Color(0xFF4CAF50)
timeProgress > 0.2f -> Color(0xFFFF9800)
else -> Color(0xFFF44336)
},
trackColor = Color.White.copy(alpha = 0.15f),
)
// Timer text
val sec = state.remainingMs / 1000
Text(
text = "${sec}s",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier.padding(top = 4.dp),
)
Spacer(modifier = Modifier.height(16.dp))
// Score + Combo
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(Res.string.score), fontSize = 12.sp, color = Color.White.copy(alpha = 0.5f))
Text(
text = "${state.score}",
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(Res.string.combo), fontSize = 12.sp, color = Color.White.copy(alpha = 0.5f))
Text(
text = "${state.combo}x",
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = if (state.combo > 0) Color(0xFFFF9800) else Color.White.copy(alpha = 0.4f),
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(Res.string.notes), fontSize = 12.sp, color = Color.White.copy(alpha = 0.5f))
Text(
text = "${state.notesPlayed}",
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Target note on staff
state.targetNote?.let { note ->
StaffView(
note = note,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
// Target note name
Text(
text = state.targetNote?.displayName ?: "",
fontSize = 72.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Text(
text = stringResource(Res.string.octave_n, (state.targetNote?.octave ?: "").toString()),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(24.dp))
// Detected note
if (state.detectedNoteName.isNotEmpty()) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.06f))
.padding(horizontal = 24.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = state.detectedNoteName,
fontSize = 40.sp,
fontWeight = FontWeight.Medium,
color = if (state.isCorrect) TunerColors.inTune else TunerColors.outOfTune,
)
}
}
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun ChallengeCompleteView(
state: ChallengeUiState,
onBack: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0xFF1A237E), Color(0xFF0D1B47)),
)
),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
imageVector = Icons.Outlined.EmojiEvents,
contentDescription = stringResource(Res.string.challenge_completed),
modifier = Modifier.size(64.dp),
tint = Color(0xFFFFD54F),
)
Text(
text = stringResource(Res.string.challenge_completed),
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Text(
text = "${state.score}",
fontSize = 64.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFFFD54F),
)
Text(
text = stringResource(Res.string.points),
fontSize = 18.sp,
color = Color.White.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.1f))
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(0.6f),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(Res.string.notes_played), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Text("${state.notesPlayed}", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
Row(
modifier = Modifier.fillMaxWidth(0.6f),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(Res.string.max_combo), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Text("${state.maxCombo}x", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color(0xFFFF9800))
}
Row(
modifier = Modifier.fillMaxWidth(0.6f),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(Res.string.time), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Text("${state.timeLimit.displayName}", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onBack) {
Text(stringResource(Res.string.finish))
}
}
}
}
@@ -0,0 +1,164 @@
package com.notemusicali.ui.challenge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.challenge.ChallengeTimeLimit
import com.notemusicali.exercises.ExerciseRepository
import com.notemusicali.music.NoteSequence
import com.notemusicali.music.ScaleDirection
import com.notemusicali.music.ScaleGenerator
import com.notemusicali.music.ScaleType
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import kotlin.random.Random
private enum class SequenceSource {
EXERCISES,
SCALES,
RANDOM,
}
@Composable
fun ChallengeSetupScreen(
onBack: () -> Unit,
onStart: (NoteSequence, ChallengeTimeLimit) -> Unit,
) {
var selectedSource by remember { mutableStateOf(SequenceSource.EXERCISES) }
var selectedTimeLimit by remember { mutableStateOf(ChallengeTimeLimit.MEDIUM) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier.widthIn(max = 600.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BackTopBar(title = stringResource(Res.string.challenge_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(Res.string.section_challenge),
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Text(
text = stringResource(Res.string.challenge_subtitle),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(40.dp))
// Sequence source
Text(stringResource(Res.string.sequence), fontSize = 16.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SequenceSource.entries.forEach { source ->
val sourceLabel = when (source) {
SequenceSource.EXERCISES -> stringResource(Res.string.source_exercises)
SequenceSource.SCALES -> stringResource(Res.string.source_scales)
SequenceSource.RANDOM -> stringResource(Res.string.source_random)
}
FilterChip(
selected = selectedSource == source,
onClick = { selectedSource = source },
label = { Text(sourceLabel) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Time limit
Text(stringResource(Res.string.time_limit), fontSize = 16.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ChallengeTimeLimit.entries.forEach { limit ->
FilterChip(
selected = selectedTimeLimit == limit,
onClick = { selectedTimeLimit = limit },
label = { Text(limit.displayName) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(48.dp))
val challengeRandomName = stringResource(Res.string.challenge_random)
Button(
onClick = {
val sequence = generateSequence(selectedSource, challengeRandomName)
onStart(sequence, selectedTimeLimit)
},
modifier = Modifier.fillMaxWidth(0.6f),
) {
Text(stringResource(Res.string.start_challenge), fontSize = 18.sp)
}
}
}
}
}
private fun generateSequence(source: SequenceSource, challengeRandomName: String): NoteSequence {
return when (source) {
SequenceSource.EXERCISES -> {
val exercises = ExerciseRepository.getAllExercises()
exercises.randomOrNull() ?: generateRandomSequence(challengeRandomName)
}
SequenceSource.SCALES -> {
val rootNote = Random.nextInt(12)
ScaleGenerator.generateScale(rootNote, 4, ScaleType.MAJOR, ScaleDirection.BOTH)
}
SequenceSource.RANDOM -> generateRandomSequence(challengeRandomName)
}
}
private fun generateRandomSequence(challengeRandomName: String): NoteSequence {
val notes = (1..12).map {
com.notemusicali.music.MusicalNote.fromMidi(Random.nextInt(60, 73))
}
return NoteSequence(name = challengeRandomName, notes = notes)
}
@@ -0,0 +1,220 @@
package com.notemusicali.ui.challenge
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.notemusicali.audio.AudioProcessor
import com.notemusicali.challenge.ChallengeRepository
import com.notemusicali.challenge.ChallengeResult
import com.notemusicali.challenge.ChallengeScoring
import com.notemusicali.challenge.ChallengeTimeLimit
import com.notemusicali.goals.DailyGoalManager
import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteSequence
import com.notemusicali.util.currentTimeMillis
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.math.abs
data class ChallengeUiState(
val sequence: NoteSequence = NoteSequence.empty(),
val timeLimit: ChallengeTimeLimit = ChallengeTimeLimit.MEDIUM,
val currentIndex: Int = 0,
val targetNote: MusicalNote? = null,
val detectedNoteName: String = "",
val detectedCents: Float = 0f,
val isCorrect: Boolean = false,
val score: Int = 0,
val combo: Int = 0,
val maxCombo: Int = 0,
val remainingMs: Long = 60_000L,
val isRunning: Boolean = false,
val isComplete: Boolean = false,
val notesPlayed: Int = 0,
val lastResult: ChallengeResult? = null,
)
class ChallengeViewModel : ViewModel() {
private val audioProcessor = AudioProcessor()
private val _uiState = MutableStateFlow(ChallengeUiState())
val uiState: StateFlow<ChallengeUiState> = _uiState.asStateFlow()
private var timerJob: Job? = null
private var noteStartTime: Long = 0L
private var firstAttemptForNote: Boolean = true
private var firstAttemptNotes: Int = 0
fun setSequence(sequence: NoteSequence) {
_uiState.value = ChallengeUiState(
sequence = sequence,
targetNote = sequence.noteAt(0),
)
}
fun setTimeLimit(limit: ChallengeTimeLimit) {
_uiState.value = _uiState.value.copy(
timeLimit = limit,
remainingMs = limit.seconds * 1000L,
)
}
fun start() {
val state = _uiState.value
_uiState.value = state.copy(
isRunning = true,
remainingMs = state.timeLimit.seconds * 1000L,
currentIndex = 0,
targetNote = state.sequence.noteAt(0),
score = 0,
combo = 0,
maxCombo = 0,
notesPlayed = 0,
isComplete = false,
)
noteStartTime = currentTimeMillis()
firstAttemptForNote = true
firstAttemptNotes = 0
// Start audio
audioProcessor.start(viewModelScope)
// Start timer
timerJob = viewModelScope.launch {
val startTime = currentTimeMillis()
val totalMs = state.timeLimit.seconds * 1000L
while (isActive) {
val elapsed = currentTimeMillis() - startTime
val remaining = (totalMs - elapsed).coerceAtLeast(0L)
_uiState.value = _uiState.value.copy(remainingMs = remaining)
if (remaining <= 0) {
endChallenge()
break
}
delay(100)
}
}
// Listen for notes
viewModelScope.launch {
audioProcessor.result.collect { result ->
val s = _uiState.value
if (!s.isRunning || s.isComplete) return@collect
when (result) {
is AudioProcessor.ProcessedResult.Detected -> {
val detected = result.noteResult
val target = s.targetNote ?: return@collect
val isCorrect = detected.note.noteIndex == target.noteIndex &&
detected.note.octave == target.octave &&
abs(detected.centsDeviation) <= 30f
_uiState.value = s.copy(
detectedNoteName = detected.note.displayName,
detectedCents = detected.centsDeviation,
isCorrect = isCorrect,
)
if (isCorrect) {
val now = currentTimeMillis()
val reactionMs = now - noteStartTime
val combo = s.combo + 1
val points = ChallengeScoring.scoreNote(
centsDeviation = detected.centsDeviation,
reactionTimeMs = reactionMs,
comboStreak = s.combo,
firstAttempt = firstAttemptForNote,
)
if (firstAttemptForNote) firstAttemptNotes++
val nextIndex = s.currentIndex + 1
val maxCombo = maxOf(s.maxCombo, combo)
if (nextIndex >= s.sequence.size) {
// Loop back to start
noteStartTime = now
firstAttemptForNote = true
_uiState.value = s.copy(
currentIndex = 0,
targetNote = s.sequence.noteAt(0),
score = s.score + points,
combo = combo,
maxCombo = maxCombo,
notesPlayed = s.notesPlayed + 1,
isCorrect = false,
detectedNoteName = "",
)
} else {
noteStartTime = now
firstAttemptForNote = true
_uiState.value = s.copy(
currentIndex = nextIndex,
targetNote = s.sequence.noteAt(nextIndex),
score = s.score + points,
combo = combo,
maxCombo = maxCombo,
notesPlayed = s.notesPlayed + 1,
isCorrect = false,
detectedNoteName = "",
)
}
} else {
firstAttemptForNote = false
if (s.combo > 0) {
_uiState.value = s.copy(
combo = 0,
detectedNoteName = detected.note.displayName,
detectedCents = detected.centsDeviation,
)
}
}
}
else -> {
_uiState.value = s.copy(
detectedNoteName = "",
isCorrect = false,
)
}
}
}
}
}
private fun endChallenge() {
timerJob?.cancel()
audioProcessor.stop()
val state = _uiState.value
val result = ChallengeResult(
id = currentTimeMillis().toString(),
dateMillis = currentTimeMillis(),
sequenceName = state.sequence.name,
timeLimitSec = state.timeLimit.seconds,
totalScore = state.score,
notesPlayed = state.notesPlayed,
maxCombo = state.maxCombo,
accuracy = ChallengeScoring.accuracy(state.notesPlayed, firstAttemptNotes),
)
ChallengeRepository.save(result)
DailyGoalManager.recordPractice(state.timeLimit.seconds * 1000L)
_uiState.value = state.copy(
isRunning = false,
isComplete = true,
lastResult = result,
)
}
override fun onCleared() {
super.onCleared()
timerJob?.cancel()
audioProcessor.stop()
}
}
@@ -0,0 +1,120 @@
package com.notemusicali.ui.challenge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.challenge.ChallengeRepository
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.util.formatEpochDate
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun LeaderboardScreen(onBack: () -> Unit) {
val results = remember { ChallengeRepository.load() }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.widthIn(max = 600.dp)) {
BackTopBar(title = stringResource(Res.string.leaderboard_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) {
if (results.isEmpty()) {
Spacer(modifier = Modifier.height(48.dp))
Text(
stringResource(Res.string.no_results),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.5f),
modifier = Modifier.align(Alignment.CenterHorizontally),
)
} else {
Spacer(modifier = Modifier.height(16.dp))
results.forEachIndexed { index, result ->
val rankColor = when (index) {
0 -> Color(0xFFFFD54F)
1 -> Color(0xFFB0BEC5)
2 -> Color(0xFFCD7F32)
else -> Color.White.copy(alpha = 0.6f)
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.06f))
.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "#${index + 1}",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = rankColor,
modifier = Modifier.widthIn(min = 48.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = result.sequenceName,
fontSize = 14.sp,
color = Color.White,
fontWeight = FontWeight.Medium,
)
Text(
text = "${result.notesPlayed} note • ${result.maxCombo}x combo • ${result.timeLimitSec}s",
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.4f),
)
Text(
text = formatEpochDate(result.dateMillis),
fontSize = 11.sp,
color = Color.White.copy(alpha = 0.3f),
)
}
Text(
text = "${result.totalScore}",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = rankColor,
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@@ -0,0 +1,41 @@
package com.notemusicali.ui.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.back
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BackTopBar(
title: String,
onBack: () -> Unit,
containerColor: Color = Color.Transparent,
contentColor: Color = Color.White,
) {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = containerColor,
titleContentColor = contentColor,
navigationIconContentColor = contentColor,
),
)
}
@@ -0,0 +1,14 @@
package com.notemusicali.ui.components
import androidx.compose.runtime.Composable
/**
* Platform-specific file picker launcher.
* Returns a lambda that, when invoked, opens the file picker.
* [onFileContent] is called with the file content as a String.
*/
@Composable
expect fun rememberFilePickerLauncher(
mimeTypes: List<String>,
onFileContent: (String) -> Unit,
): () -> Unit
@@ -0,0 +1,8 @@
package com.notemusicali.ui.components
import androidx.compose.runtime.Composable
@Composable
expect fun rememberFolderPickerLauncher(
onFolderSelected: (uri: String, displayName: String) -> Unit,
): () -> Unit
@@ -0,0 +1,56 @@
package com.notemusicali.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun GradientCard(
gradient: Brush,
onClick: () -> Unit,
modifier: Modifier = Modifier,
accentColor: Color = Color.Transparent,
content: @Composable ColumnScope.() -> Unit,
) {
val shape = RoundedCornerShape(20.dp)
Box(modifier = modifier.clip(shape)) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(gradient)
.border(1.dp, Color.White.copy(alpha = 0.10f), shape)
.clickable(onClick = onClick)
.padding(20.dp),
content = content,
)
// Top accent line (like Remotion feature cards)
if (accentColor != Color.Transparent) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth(0.6f)
.height(2.dp)
.background(
Brush.horizontalGradient(
listOf(Color.Transparent, accentColor, Color.Transparent),
),
),
)
}
}
}
@@ -0,0 +1,6 @@
package com.notemusicali.ui.components
import androidx.compose.runtime.Composable
@Composable
expect fun KeepScreenOn()
@@ -0,0 +1,170 @@
package com.notemusicali.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.music.MusicalNote
/**
* Shows a window of notes around the current index, with the current note highlighted.
* [windowSize] controls how many notes are visible (odd number works best).
*/
@Composable
fun StaffContextView(
notes: List<MusicalNote>,
currentIndex: Int,
modifier: Modifier = Modifier,
windowSize: Int = 7,
lightTheme: Boolean = false,
beats: Int = 4,
beatType: Int = 4,
) {
if (notes.isEmpty()) return
val textMeasurer = rememberTextMeasurer()
Canvas(
modifier = modifier
.fillMaxWidth()
.height(140.dp),
) {
val lineColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.4f)
val dimNoteColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.35f)
val playedNoteColor = if (lightTheme) Color.Black.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.2f)
val currentNoteColor = Color(0xFFFFD54F)
val clefColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.8f)
val lineSpacing = size.height / 10f
val staffBottom = size.height / 2f + lineSpacing * 2f
val halfSpacing = lineSpacing / 2f
val leftMargin = size.width * 0.06f
val rightMargin = size.width * 0.02f
val useTreble = StaffLayout.useTrebleClef(notes)
// Staff lines
for (i in 0..4) {
val y = staffBottom - i * lineSpacing
drawLine(lineColor, Offset(leftMargin, y), Offset(size.width - rightMargin, y), 1.5f)
}
// Determine visible window
val half = windowSize / 2
val startIdx = (currentIndex - half).coerceAtLeast(0)
val endIdx = (startIdx + windowSize).coerceAtMost(notes.size)
val visibleNotes = notes.subList(startIdx, endIdx)
// Clef (+ time signature when the window includes the start of the piece)
var cursorX = leftMargin + 2f
cursorX += drawClef(textMeasurer, useTreble, cursorX, staffBottom, lineSpacing, clefColor)
if (startIdx == 0) {
cursorX += 6f
cursorX += drawTimeSignature(textMeasurer, beats, beatType, cursorX, staffBottom, lineSpacing, clefColor)
}
val noteAreaStart = cursorX + 10f
val noteAreaEnd = size.width - rightMargin - 8f
val noteAreaWidth = noteAreaEnd - noteAreaStart
val xs = StaffLayout.xPositions(visibleNotes, noteAreaStart, noteAreaWidth)
// Beam groups on the full sequence, clipped to the visible window (window-local indices)
val groups = StaffLayout.beamGroups(notes, beats, beatType)
.mapNotNull { g ->
val from = maxOf(g.first, startIdx)
val to = minOf(g.last, endIdx - 1)
if (to - from >= 1) (from - startIdx)..(to - startIdx) else null
}
val grouped = groups.flatMap { it }.toSet()
val bars = StaffLayout.measurePositions(notes, beats, beatType).toSet()
val headHalf = staffHeadHalfWidth(lineSpacing)
val ledgerHalf = lineSpacing * 0.8f
visibleNotes.forEachIndexed { vi, note ->
val actualIdx = startIdx + vi
val nx = xs[vi]
val isCurrent = actualIdx == currentIndex
val isPlayed = actualIdx < currentIndex
val color = when {
isCurrent -> currentNoteColor
isPlayed -> playedNoteColor
else -> dimNoteColor
}
val relPos = StaffLayout.relativePosition(note, useTreble)
val ny = staffBottom - relPos * halfSpacing
// Ledger lines
var lp = -2
while (lp >= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(lineColor.copy(alpha = 0.3f), Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
lp -= 2
}
lp = 10
while (lp <= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(lineColor.copy(alpha = 0.3f), Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
lp += 2
}
// Note head
drawNoteHeadPath(nx, ny, lineSpacing, note.duration, color)
// Stem + flags (only for notes not part of a beamed group)
if (vi !in grouped) {
val stemUp = relPos < 4
val stemTip = drawStem(nx, ny, lineSpacing, stemUp, note.duration, color)
stemTip?.let { drawCurvedFlags(it, stemUp, note.duration, lineSpacing, color) }
}
// Sharp
drawAccidentalGlyph(note, nx - headHalf - lineSpacing * 0.5f, ny, lineSpacing * 0.35f, color)
// Barline between this note and the next
if (actualIdx in bars && vi < visibleNotes.lastIndex) {
drawBarline((xs[vi] + xs[vi + 1]) / 2f, staffBottom, lineSpacing, lineColor)
}
// Current note highlight circle
if (isCurrent) {
drawCircle(currentNoteColor.copy(alpha = 0.15f), radius = lineSpacing * 2f, center = Offset(nx, ny))
}
// Note name under current
if (isCurrent) {
val labelStyle = TextStyle(color = currentNoteColor, fontSize = 12.sp)
val labelResult = textMeasurer.measure(note.fullName, labelStyle)
drawText(labelResult, topLeft = Offset(nx - labelResult.size.width / 2f, staffBottom + lineSpacing * 1.2f))
}
}
// Beamed groups
val currentVi = currentIndex - startIdx
groups.forEach { group ->
val gxs = group.map { xs[it] }
val relPositions = group.map { StaffLayout.relativePosition(visibleNotes[it], useTreble) }
val gys = relPositions.map { staffBottom - it * halfSpacing }
val gdurations = group.map { visibleNotes[it].duration }
val groupColor = if (currentVi in group) currentNoteColor else dimNoteColor
drawBeamedGroup(
xs = gxs,
ys = gys,
durations = gdurations,
stemUp = StaffLayout.stemUpForGroup(relPositions),
lineSpacing = lineSpacing,
color = groupColor,
)
}
}
}
@@ -0,0 +1,205 @@
package com.notemusicali.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import com.notemusicali.music.MusicalNote
/**
* Multi-line staff showing all notes with vertical scroll and auto-scroll to
* the current line. Lines wrap at measure boundaries when possible, notes are
* spaced proportionally to their duration, and fast notes are beamed.
*/
@Composable
fun StaffFullView(
notes: List<MusicalNote>,
currentIndex: Int,
modifier: Modifier = Modifier,
lightTheme: Boolean = false,
beats: Int = 4,
beatType: Int = 4,
) {
if (notes.isEmpty()) return
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
val scrollState = rememberScrollState()
val systemHeightDp = 100.dp
val systemGapDp = 16.dp
val clefWidthDp = 36.dp
val noteSpacingDp = 36.dp
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val availableWidthDp = maxWidth
val noteAreaWidthDp = availableWidthDp - clefWidthDp - 8.dp
val notesPerLine = (noteAreaWidthDp / noteSpacingDp).toInt().coerceAtLeast(4)
val bars = StaffLayout.measurePositions(notes, beats, beatType)
// Righe: tetto notesPerLine; si chiude preferibilmente all'ultima stanghetta che ci sta
val lines = mutableListOf<IntRange>()
var lineStart = 0
while (lineStart < notes.size) {
val cap = (lineStart + notesPerLine - 1).coerceAtMost(notes.size - 1)
val lastBarInLine = bars.lastOrNull { it in lineStart..cap }
val lineEnd = when {
cap == notes.size - 1 -> cap
lastBarInLine != null && lastBarInLine > lineStart -> lastBarInLine
else -> cap
}
lines.add(lineStart..lineEnd)
lineStart = lineEnd + 1
}
val lineCount = lines.size
val totalHeightDp = systemHeightDp * lineCount + systemGapDp * (lineCount - 1).coerceAtLeast(0)
// Auto-scroll to current line
val lineOfNote = IntArray(notes.size)
lines.forEachIndexed { li, r -> r.forEach { lineOfNote[it] = li } }
val currentLine = lineOfNote.getOrElse(currentIndex) { 0 }
LaunchedEffect(currentLine) {
val systemHeightPx = with(density) { (systemHeightDp + systemGapDp).toPx() }
val viewportHeight = scrollState.viewportSize
val targetY = (systemHeightPx * currentLine - viewportHeight / 3f)
.toInt().coerceAtLeast(0)
scrollState.animateScrollTo(targetY)
}
Canvas(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
.height(totalHeightDp),
) {
val lineColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.4f)
val dimColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.35f)
val playedColor = if (lightTheme) Color.Black.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.2f)
val currentColor = Color(0xFFFFD54F)
val clefColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.8f)
val systemHeightPx = systemHeightDp.toPx()
val systemGapPx = systemGapDp.toPx()
val lineSpacing = systemHeightPx / 8f
val halfSpacing = lineSpacing / 2f
val staffRelBottom = systemHeightPx / 2f + lineSpacing * 2f
val useTreble = StaffLayout.useTrebleClef(notes)
val headHalf = staffHeadHalfWidth(lineSpacing)
val ledgerHalf = lineSpacing * 0.75f
lines.forEachIndexed { li, r ->
val systemTop = li * (systemHeightPx + systemGapPx)
val staffBottom = systemTop + staffRelBottom
// Staff lines
for (i in 0..4) {
val y = staffBottom - i * lineSpacing
drawLine(lineColor, Offset(0f, y), Offset(size.width, y), 1.2f)
}
// Clef (+ time signature on the first line only)
var cursor = 4f
cursor += drawClef(textMeasurer, useTreble, cursor, staffBottom, lineSpacing, clefColor)
if (li == 0) {
cursor += 6f
cursor += drawTimeSignature(textMeasurer, beats, beatType, cursor, staffBottom, lineSpacing, clefColor)
}
val lineNotes = notes.subList(r.first, r.last + 1)
val xs = StaffLayout.xPositions(lineNotes, cursor + 10f, size.width - cursor - 18f)
val groups = StaffLayout.beamGroups(lineNotes, beats, beatType)
val grouped = groups.flatMap { it }.toSet()
val currentInLine = currentIndex - r.first
lineNotes.forEachIndexed { vi, note ->
val actualIdx = r.first + vi
val nx = xs[vi]
val isCurrent = actualIdx == currentIndex
val isPlayed = actualIdx < currentIndex
val color = when {
isCurrent -> currentColor
isPlayed -> playedColor
else -> dimColor
}
val relPos = StaffLayout.relativePosition(note, useTreble)
val ny = staffBottom - relPos * halfSpacing
// Ledger lines
var lp = -2
while (lp >= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(lineColor.copy(alpha = 0.3f), Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
lp -= 2
}
lp = 10
while (lp <= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(lineColor.copy(alpha = 0.3f), Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
lp += 2
}
// Note head
drawNoteHeadPath(nx, ny, lineSpacing, note.duration, color)
// Stem + flags (only for notes not part of a beamed group)
if (vi !in grouped) {
val stemUp = relPos < 4
val stemTip = drawStem(nx, ny, lineSpacing, stemUp, note.duration, color)
stemTip?.let { drawCurvedFlags(it, stemUp, note.duration, lineSpacing, color) }
}
// Accidental
drawAccidentalGlyph(note, nx - headHalf - lineSpacing * 0.5f, ny, lineSpacing * 0.35f, color)
// Current note highlight circle
if (isCurrent) {
drawCircle(currentColor.copy(alpha = 0.15f), radius = lineSpacing * 1.8f, center = Offset(nx, ny))
}
}
// Beamed groups
groups.forEach { group ->
val gxs = group.map { xs[it] }
val relPositions = group.map { StaffLayout.relativePosition(lineNotes[it], useTreble) }
val gys = relPositions.map { staffBottom - it * halfSpacing }
val gdurations = group.map { lineNotes[it].duration }
val groupColor = if (currentInLine in group) currentColor else dimColor
drawBeamedGroup(
xs = gxs,
ys = gys,
durations = gdurations,
stemUp = StaffLayout.stemUpForGroup(relPositions),
lineSpacing = lineSpacing,
color = groupColor,
)
}
// Barlines
bars.forEach { b ->
if (b in r && b < r.last) {
drawBarline((xs[b - r.first] + xs[b - r.first + 1]) / 2f, staffBottom, lineSpacing, lineColor)
}
}
if (r.last in bars) {
drawBarline(size.width - 8f, staffBottom, lineSpacing, lineColor)
}
}
}
}
}
@@ -0,0 +1,171 @@
package com.notemusicali.ui.components
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteDuration
/**
* Shared staff-layout math for every staff renderer (StaffView, StaffContextView,
* StaffFullView). Positions are expressed in half-line steps: each unit is half
* the distance between two staff lines.
*/
object StaffLayout {
// Chromatic index (0-11) to diatonic step (0-6): sharps sit on their natural's line
private val DIATONIC_MAP = intArrayOf(0, 0, 1, 1, 2, 3, 3, 4, 4, 5, 5, 6)
// Black-key chromatic indices, rendered with a sharp accidental
private val ACCIDENTAL_INDICES = setOf(1, 3, 6, 8, 10)
private const val TREBLE_CLEF = "𝄞" // 𝄞
private const val BASS_CLEF = "𝄢" // 𝄢
// Diatonic position of the staff's bottom line: E4 (treble) / G2 (bass)
private const val TREBLE_BOTTOM_LINE = 30
private const val BASS_BOTTOM_LINE = 18
enum class Accidental { SHARP, FLAT }
fun useTrebleClef(note: MusicalNote): Boolean = note.octave >= 3
fun useTrebleClef(notes: List<MusicalNote>): Boolean = notes.any { it.octave >= 3 }
fun diatonicPosition(note: MusicalNote): Int {
// A flat sits on the line of the natural above it (Eb on E's line),
// a sharp on the line of the natural below it (D# on D's line).
// Black keys are never at index 11, so +1 cannot wrap the octave.
val diatonicIndex = if (note.preferFlat && note.noteIndex in ACCIDENTAL_INDICES) {
DIATONIC_MAP[note.noteIndex + 1]
} else {
DIATONIC_MAP[note.noteIndex]
}
return note.octave * 7 + diatonicIndex
}
fun accidentalFor(note: MusicalNote): Accidental? = when {
note.noteIndex !in ACCIDENTAL_INDICES -> null
note.preferFlat -> Accidental.FLAT
else -> Accidental.SHARP
}
fun bottomLinePosition(useTreble: Boolean): Int =
if (useTreble) TREBLE_BOTTOM_LINE else BASS_BOTTOM_LINE
/** Half-line steps above the staff's bottom line (0 = on the bottom line). */
fun relativePosition(note: MusicalNote, useTreble: Boolean): Int =
diatonicPosition(note) - bottomLinePosition(useTreble)
fun needsSharp(note: MusicalNote): Boolean = note.noteIndex in ACCIDENTAL_INDICES
fun clefSymbol(useTreble: Boolean): String = if (useTreble) TREBLE_CLEF else BASS_CLEF
/** Durata in quarti (semiminima = 1). */
fun quarterUnits(duration: NoteDuration): Double = when (duration) {
NoteDuration.WHOLE -> 4.0
NoteDuration.HALF -> 2.0
NoteDuration.QUARTER -> 1.0
NoteDuration.EIGHTH -> 0.5
NoteDuration.SIXTEENTH -> 0.25
NoteDuration.THIRTY_SECOND -> 0.125
}
/** Gruppi (indici) di note veloci consecutive che iniziano nello stesso beat. */
fun beamGroups(notes: List<MusicalNote>, beats: Int, beatType: Int): List<IntRange> {
if (beats <= 0 || beatType <= 0) return emptyList()
val beatLen = if (beatType == 8 && beats % 3 == 0) 1.5 else 4.0 / beatType
val groups = mutableListOf<IntRange>()
var groupStart = -1
var groupBeat = -1
var pos = 0.0
fun close(endExclusive: Int) {
if (groupStart >= 0 && endExclusive - groupStart >= 2) groups.add(groupStart until endExclusive)
groupStart = -1
}
notes.forEachIndexed { i, note ->
val beamable = quarterUnits(note.duration) <= 0.5
val beat = (pos / beatLen).toInt()
if (beamable) {
if (groupStart < 0) { groupStart = i; groupBeat = beat }
else if (beat != groupBeat) { close(i); groupStart = i; groupBeat = beat }
} else {
close(i)
}
pos += quarterUnits(note.duration)
}
close(notes.size)
return groups
}
/** Gambi in su se almeno metà delle note del gruppo sta sotto la linea mediana. */
fun stemUpForGroup(relPositions: List<Int>): Boolean =
relPositions.count { it < 4 } * 2 >= relPositions.size
/** Indici di nota DOPO i quali cade una stanghetta (inclusa l'eventuale finale). */
fun measurePositions(notes: List<MusicalNote>, beats: Int, beatType: Int): List<Int> {
val capacity = beats * (4.0 / beatType)
if (capacity <= 0.0) return emptyList()
val positions = mutableListOf<Int>()
var acc = 0.0
notes.forEachIndexed { i, note ->
acc += quarterUnits(note.duration)
if (acc >= capacity - 1e-9) {
positions.add(i)
acc = 0.0
}
}
return positions
}
/** Centri orizzontali proporzionali a sqrt(durata): la nota lunga respira. */
fun xPositions(notes: List<MusicalNote>, areaStart: Float, areaWidth: Float): List<Float> {
if (notes.isEmpty()) return emptyList()
val weights = notes.map { kotlin.math.sqrt(quarterUnits(it.duration)).toFloat() }
val total = weights.sum()
var cursor = 0f
return weights.map { w ->
val center = areaStart + (cursor + w / 2f) / total * areaWidth
cursor += w
center
}
}
}
/** Draws the accidental glyph appropriate for [note], if any. */
fun DrawScope.drawAccidentalGlyph(note: MusicalNote, cx: Float, cy: Float, s: Float, color: Color) {
when (StaffLayout.accidentalFor(note)) {
StaffLayout.Accidental.SHARP -> drawSharpGlyph(cx, cy, s, color)
StaffLayout.Accidental.FLAT -> drawFlatGlyph(cx, cy, s, color)
null -> Unit
}
}
/** Flat glyph (♭) drawn with strokes: vertical stem plus a bowl bulging right. */
fun DrawScope.drawFlatGlyph(cx: Float, cy: Float, s: Float, color: Color) {
val sw = 1.8f
val stemX = cx - s * 0.4f
drawLine(color, Offset(stemX, cy - s * 1.6f), Offset(stemX, cy + s * 0.9f), sw)
val bowl = Path().apply {
moveTo(stemX, cy - s * 0.1f)
cubicTo(
cx + s * 0.8f, cy - s * 0.5f,
cx + s * 0.8f, cy + s * 0.5f,
stemX, cy + s * 0.9f,
)
}
drawPath(bowl, color, style = Stroke(width = sw))
}
/** Sharp glyph drawn with strokes (Unicode ♯ renders unreliably across platforms). */
fun DrawScope.drawSharpGlyph(cx: Float, cy: Float, s: Float, color: Color) {
val sw = 1.8f
// Two vertical strokes
drawLine(color, Offset(cx - s * 0.3f, cy - s * 1.2f), Offset(cx - s * 0.3f, cy + s * 1.2f), sw)
drawLine(color, Offset(cx + s * 0.3f, cy - s * 1.2f), Offset(cx + s * 0.3f, cy + s * 1.2f), sw)
// Two horizontal strokes (slightly tilted)
drawLine(color, Offset(cx - s * 0.7f, cy - s * 0.35f), Offset(cx + s * 0.7f, cy - s * 0.55f), sw + 0.5f)
drawLine(color, Offset(cx - s * 0.7f, cy + s * 0.55f), Offset(cx + s * 0.7f, cy + s * 0.35f), sw + 0.5f)
}
@@ -0,0 +1,199 @@
package com.notemusicali.ui.components
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import com.notemusicali.music.NoteDuration
/** Primitive di disegno del pentagramma condivise da tutte le viste. */
private fun unit(lineSpacing: Float) = lineSpacing * 0.09f
internal fun staffHeadHalfWidth(lineSpacing: Float): Float = 7.5f * unit(lineSpacing)
/** Testa "a pera": piena per <= QUARTER, contorno per WHOLE/HALF. */
internal fun DrawScope.drawNoteHeadPath(
cx: Float, cy: Float,
lineSpacing: Float,
duration: NoteDuration,
color: Color,
) {
val u = unit(lineSpacing)
val path = Path().apply {
moveTo(cx - 7.4f * u, cy + 2.6f * u)
quadraticTo(cx - 5.4f * u, cy - 4.8f * u, cx + 2.4f * u, cy - 4.9f * u)
quadraticTo(cx + 7.6f * u, cy - 4.9f * u, cx + 7.4f * u, cy - 0.9f * u)
quadraticTo(cx + 6.8f * u, cy + 4.9f * u, cx - 1.6f * u, cy + 5.0f * u)
quadraticTo(cx - 7.4f * u, cy + 5.0f * u, cx - 7.4f * u, cy + 2.6f * u)
close()
}
val hollow = duration == NoteDuration.WHOLE || duration == NoteDuration.HALF
if (hollow) drawPath(path, color, style = Stroke(width = lineSpacing * 0.16f))
else drawPath(path, color)
}
/** Gambo attaccato al bordo della testa; ritorna l'estremità (null per la semibreve). */
internal fun DrawScope.drawStem(
cx: Float, cy: Float,
lineSpacing: Float,
stemUp: Boolean,
duration: NoteDuration,
color: Color,
tipY: Float? = null,
): Offset? {
if (duration == NoteDuration.WHOLE) return null
val u = unit(lineSpacing)
val stemH = lineSpacing * 3.2f
val x = if (stemUp) cx + 7.0f * u else cx - 7.0f * u
val endY = tipY ?: if (stemUp) cy - stemH else cy + stemH
drawLine(color, Offset(x, cy), Offset(x, endY), lineSpacing * 0.14f)
return Offset(x, endY)
}
/** Code a goccia riempite (1-3), specchiate per gambo in giù. */
internal fun DrawScope.drawCurvedFlags(
stemTip: Offset,
stemUp: Boolean,
duration: NoteDuration,
lineSpacing: Float,
color: Color,
) {
val count = when (duration) {
NoteDuration.EIGHTH -> 1
NoteDuration.SIXTEENTH -> 2
NoteDuration.THIRTY_SECOND -> 3
else -> return
}
val u = unit(lineSpacing)
val dir = if (stemUp) 1f else -1f
for (i in 0 until count) {
val oy = stemTip.y + dir * i * 5.5f * u
val path = Path().apply {
moveTo(stemTip.x, oy)
cubicTo(
stemTip.x + 7f * u, oy + dir * 4f * u,
stemTip.x + 10f * u, oy + dir * 11f * u,
stemTip.x + 6.5f * u, oy + dir * 21f * u,
)
cubicTo(
stemTip.x + 10.5f * u, oy + dir * 12f * u,
stemTip.x + 8.5f * u, oy + dir * 6f * u,
stemTip.x, oy + dir * 6f * u,
)
close()
}
drawPath(path, color)
}
}
/**
* Gruppo travato: gambi allineati a una travatura inclinata (pendenza clampata
* a una lineSpacing) più la seconda barra tra coppie di semicrome.
*/
internal fun DrawScope.drawBeamedGroup(
xs: List<Float>, ys: List<Float>,
durations: List<NoteDuration>,
stemUp: Boolean,
lineSpacing: Float,
color: Color,
) {
if (xs.size < 2) return
val u = unit(lineSpacing)
val stemH = lineSpacing * 3.2f
val dir = if (stemUp) -1f else 1f
val attachX = xs.map { if (stemUp) it + 7.0f * u else it - 7.0f * u }
// Estremità ideali, poi pendenza clampata; la travatura deve superare tutte le teste
var y0 = ys.first() + dir * stemH
var y1 = ys.last() + dir * stemH
if (y1 - y0 > lineSpacing) y1 = y0 + lineSpacing
if (y0 - y1 > lineSpacing) y0 = y1 + lineSpacing
val minClearance = lineSpacing * 2.2f
ys.forEachIndexed { i, noteY ->
val t = (attachX[i] - attachX.first()) / (attachX.last() - attachX.first())
val beamY = y0 + (y1 - y0) * t
val clearance = (noteY - beamY) * -dir
if (clearance < minClearance) {
val shift = (minClearance - clearance) * dir
y0 += shift; y1 += shift
}
}
fun beamYAt(x: Float) = y0 + (y1 - y0) * (x - attachX.first()) / (attachX.last() - attachX.first())
// Gambi fino alla travatura
xs.indices.forEach { i ->
drawLine(color, Offset(attachX[i], ys[i]), Offset(attachX[i], beamYAt(attachX[i])), lineSpacing * 0.14f)
}
// La travatura si ispessisce VERSO le teste: in giù se i gambi sono in su
val thicken = lineSpacing * 0.45f * (if (stemUp) 1f else -1f)
fun beamSegment(fromX: Float, toX: Float, offset: Float) {
val path = Path().apply {
moveTo(fromX, beamYAt(fromX) + offset)
lineTo(toX, beamYAt(toX) + offset)
lineTo(toX, beamYAt(toX) + offset + thicken)
lineTo(fromX, beamYAt(fromX) + offset + thicken)
close()
}
drawPath(path, color)
}
// Barra primaria su tutto il gruppo
beamSegment(attachX.first(), attachX.last(), 0f)
// Barra secondaria tra coppie adiacenti di semicrome (o più corte)
val secondOffset = lineSpacing * 0.75f * (if (stemUp) 1f else -1f)
for (i in 0 until xs.size - 1) {
val bothShort = StaffLayout.quarterUnits(durations[i]) <= 0.25 && StaffLayout.quarterUnits(durations[i + 1]) <= 0.25
if (bothShort) beamSegment(attachX[i], attachX[i + 1], secondOffset)
}
}
/** Stanghetta di battuta. */
internal fun DrawScope.drawBarline(x: Float, staffBottom: Float, lineSpacing: Float, color: Color) {
drawLine(color, Offset(x, staffBottom - 4 * lineSpacing), Offset(x, staffBottom), lineSpacing * 0.1f)
}
/** Cifre del tempo (es. 4/4) dopo la chiave. Ritorna la larghezza occupata. */
internal fun DrawScope.drawTimeSignature(
textMeasurer: TextMeasurer,
beats: Int, beatType: Int,
x: Float, staffBottom: Float, lineSpacing: Float,
color: Color,
): Float {
val style = TextStyle(
color = color,
fontSize = (lineSpacing * 1.9f).toSp(),
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Serif,
)
val top = textMeasurer.measure(beats.toString(), style)
val bottom = textMeasurer.measure(beatType.toString(), style)
drawText(top, topLeft = Offset(x, staffBottom - 4f * lineSpacing + (2f * lineSpacing - top.size.height) / 2f))
drawText(bottom, topLeft = Offset(x, staffBottom - 2f * lineSpacing + (2f * lineSpacing - bottom.size.height) / 2f))
return maxOf(top.size.width, bottom.size.width).toFloat()
}
/** Chiave a piena opacità. Ritorna la larghezza occupata. */
internal fun DrawScope.drawClef(
textMeasurer: TextMeasurer,
useTreble: Boolean,
x: Float, staffBottom: Float, lineSpacing: Float,
color: Color,
): Float {
val style = TextStyle(color = color, fontSize = (lineSpacing * 3.4f).toSp(), fontFamily = FontFamily.Serif)
val result = textMeasurer.measure(StaffLayout.clefSymbol(useTreble), style)
val y = if (useTreble) {
(staffBottom - lineSpacing) - result.size.height * 0.42f
} else {
(staffBottom - 3 * lineSpacing) - result.size.height * 0.35f
}
drawText(result, topLeft = Offset(x, y))
return result.size.width.toFloat()
}
@@ -0,0 +1,214 @@
package com.notemusicali.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.music.MusicalNote
@Composable
fun StaffView(
note: MusicalNote,
modifier: Modifier = Modifier,
lightTheme: Boolean = false,
) {
val textMeasurer = rememberTextMeasurer()
Canvas(
modifier = modifier
.fillMaxWidth()
.height(160.dp),
) {
val lineColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.5f)
val noteColor = if (lightTheme) Color.Black else Color.White
val lineSpacing = size.height / 12f
val staffBottom = size.height / 2f + lineSpacing * 2f
val halfSpacing = lineSpacing / 2f
val leftMargin = size.width * 0.06f
val rightMargin = size.width * 0.02f
val noteX = size.width * 0.5f
val useTreble = StaffLayout.useTrebleClef(note)
val relPos = StaffLayout.relativePosition(note, useTreble)
val noteY = staffBottom - relPos * halfSpacing
// --- Staff lines ---
for (i in 0..4) {
val y = staffBottom - i * lineSpacing
drawLine(
color = lineColor,
start = Offset(leftMargin, y),
end = Offset(size.width - rightMargin, y),
strokeWidth = 1.5f,
)
}
// --- Clef symbol ---
drawClef(textMeasurer, useTreble, leftMargin + 2f, staffBottom, lineSpacing, noteColor)
// --- Ledger lines ---
val ledgerHalf = lineSpacing * 0.9f
// Below staff
var lp = -2
while (lp >= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(
color = lineColor,
start = Offset(noteX - ledgerHalf, ly),
end = Offset(noteX + ledgerHalf, ly),
strokeWidth = 1.5f,
)
lp -= 2
}
// Above staff
lp = 10
while (lp <= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(
color = lineColor,
start = Offset(noteX - ledgerHalf, ly),
end = Offset(noteX + ledgerHalf, ly),
strokeWidth = 1.5f,
)
lp += 2
}
// --- Note head + stem + flags ---
drawNoteHeadPath(noteX, noteY, lineSpacing, note.duration, noteColor)
val stemUp = relPos < 4
val tip = drawStem(noteX, noteY, lineSpacing, stemUp, note.duration, noteColor)
tip?.let { drawCurvedFlags(it, stemUp, note.duration, lineSpacing, noteColor) }
// --- Accidental (sharp) ---
drawAccidentalGlyph(note, noteX - staffHeadHalfWidth(lineSpacing) - lineSpacing * 0.7f, noteY, lineSpacing * 0.45f, noteColor)
// --- Note name label under staff ---
val labelStyle = TextStyle(
color = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.6f),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
val labelResult = textMeasurer.measure(note.fullName, labelStyle)
val labelY = staffBottom + lineSpacing * 1.5f
drawText(
textLayoutResult = labelResult,
topLeft = Offset(
noteX - labelResult.size.width / 2f,
labelY,
),
)
}
}
/**
* Compact staff preview showing all notes of a sequence.
*/
@Composable
fun StaffPreview(
notes: List<MusicalNote>,
modifier: Modifier = Modifier,
beats: Int = 4,
beatType: Int = 4,
) {
if (notes.isEmpty()) return
val textMeasurer = rememberTextMeasurer()
Canvas(
modifier = modifier
.fillMaxWidth()
.height(80.dp),
) {
val lineColor = Color.White.copy(alpha = 0.3f)
val noteColor = Color.White.copy(alpha = 0.85f)
val lineSpacing = size.height / 8f
val staffBottom = size.height / 2f + lineSpacing * 2f
val halfSpacing = lineSpacing / 2f
val leftMargin = size.width * 0.06f
val rightMargin = size.width * 0.02f
val useTreble = StaffLayout.useTrebleClef(notes)
// --- Staff lines ---
for (i in 0..4) {
val y = staffBottom - i * lineSpacing
drawLine(
color = lineColor,
start = Offset(leftMargin, y),
end = Offset(size.width - rightMargin, y),
strokeWidth = 1f,
)
}
// --- Clef symbol ---
drawClef(textMeasurer, useTreble, 4f, staffBottom, lineSpacing, noteColor)
// --- Notes ---
val noteAreaStart = leftMargin + 8f
val noteAreaEnd = size.width - rightMargin - 8f
val noteAreaWidth = noteAreaEnd - noteAreaStart
val xs = StaffLayout.xPositions(notes, noteAreaStart, noteAreaWidth)
val relPositions = notes.map { StaffLayout.relativePosition(it, useTreble) }
val ys = relPositions.map { staffBottom - it * halfSpacing }
val groups = StaffLayout.beamGroups(notes, beats, beatType)
val grouped = groups.flatMap { it }.toSet()
val ledgerHalf = lineSpacing * 0.7f
notes.forEachIndexed { index, note ->
val nx = xs[index]
val relPos = relPositions[index]
val ny = ys[index]
// Ledger lines for this note
var lp = -2
while (lp >= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(lineColor, Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
lp -= 2
}
lp = 10
while (lp <= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(lineColor, Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
lp += 2
}
// Note head
drawNoteHeadPath(nx, ny, lineSpacing, note.duration, noteColor)
// Stem + flags (beamed notes get their stems from the beam group)
if (index !in grouped) {
val stemUp = relPos < 4
val tip = drawStem(nx, ny, lineSpacing, stemUp, note.duration, noteColor)
tip?.let { drawCurvedFlags(it, stemUp, note.duration, lineSpacing, noteColor) }
}
// Sharp
drawAccidentalGlyph(note, nx - staffHeadHalfWidth(lineSpacing) - lineSpacing * 0.5f, ny, lineSpacing * 0.3f, noteColor)
}
// --- Beamed groups ---
groups.forEach { group ->
val gxs = group.map { xs[it] }
val gys = group.map { ys[it] }
val gdur = group.map { notes[it].duration }
val stemUp = StaffLayout.stemUpForGroup(group.map { relPositions[it] })
drawBeamedGroup(gxs, gys, gdur, stemUp, lineSpacing, noteColor)
}
}
}
@@ -0,0 +1,316 @@
package com.notemusicali.ui.eartraining
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.notemusicali.eartraining.EarTrainingDifficulty
import com.notemusicali.eartraining.EarTrainingMode
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun EarTrainingScreen(
onBack: () -> Unit,
viewModel: EarTrainingViewModel = viewModel { EarTrainingViewModel() },
) {
val state by viewModel.uiState.collectAsState()
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.widthIn(max = 600.dp)) {
BackTopBar(title = stringResource(Res.string.ear_training_title), onBack = onBack)
if (state.isSessionComplete) {
SessionCompleteView(
correct = state.correctCount,
total = state.totalQuestions,
onRestart = { viewModel.startSession() },
onBack = onBack,
)
} else if (state.currentQuestion != null) {
QuestionView(
state = state,
onPlay = { viewModel.playSound() },
onAnswer = { viewModel.submitAnswer(it) },
)
} else {
SetupView(
state = state,
onModeChange = { viewModel.setMode(it) },
onDifficultyChange = { viewModel.setDifficulty(it) },
onStart = { viewModel.startSession() },
)
}
}
}
}
@Composable
private fun SetupView(
state: EarTrainingUiState,
onModeChange: (EarTrainingMode) -> Unit,
onDifficultyChange: (EarTrainingDifficulty) -> Unit,
onStart: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// Mode tabs
val modeIndex = EarTrainingMode.entries.indexOf(state.mode)
TabRow(
selectedTabIndex = modeIndex,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.primary,
) {
EarTrainingMode.entries.forEach { mode ->
Tab(
selected = state.mode == mode,
onClick = { onModeChange(mode) },
) {
Text(mode.displayName, modifier = Modifier.padding(12.dp))
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// Difficulty
Text(stringResource(Res.string.difficulty), fontSize = 16.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
EarTrainingDifficulty.entries.forEach { diff ->
FilterChip(
selected = state.difficulty == diff,
onClick = { onDifficultyChange(diff) },
label = { Text(diff.displayName) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(48.dp))
Button(
onClick = onStart,
modifier = Modifier.fillMaxWidth(0.6f),
) {
Text(stringResource(Res.string.start), fontSize = 18.sp)
}
}
}
@Composable
private fun QuestionView(
state: EarTrainingUiState,
onPlay: () -> Unit,
onAnswer: (String) -> Unit,
) {
val question = state.currentQuestion ?: return
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(16.dp))
// Progress
Text(
text = stringResource(Res.string.question_n, state.questionNumber, state.totalQuestions),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.5f),
)
// Score
Text(
text = stringResource(Res.string.correct_count, state.correctCount),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF4CAF50),
)
Spacer(modifier = Modifier.height(32.dp))
// Play button
IconButton(
onClick = onPlay,
modifier = Modifier
.size(100.dp)
.clip(CircleShape)
.background(
if (state.isPlaying) Color(0xFFFF9800).copy(alpha = 0.3f)
else MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
),
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(Res.string.play_sound),
tint = Color.White,
modifier = Modifier.size(48.dp),
)
}
Text(
text = if (state.isPlaying) stringResource(Res.string.listening) else stringResource(Res.string.press_to_listen),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.5f),
modifier = Modifier.padding(top = 8.dp),
)
Spacer(modifier = Modifier.height(40.dp))
// Answer choices (2x2 grid)
val choices = question.choices
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
for (row in choices.chunked(2)) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
row.forEach { choice ->
val bgColor = when {
state.selectedAnswer == null -> Color.White.copy(alpha = 0.1f)
choice == question.correctAnswer -> Color(0xFF4CAF50).copy(alpha = 0.3f)
choice == state.selectedAnswer -> Color(0xFFF44336).copy(alpha = 0.3f)
else -> Color.White.copy(alpha = 0.05f)
}
Button(
onClick = { onAnswer(choice) },
enabled = state.selectedAnswer == null,
modifier = Modifier
.weight(1f)
.height(56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = bgColor,
disabledContainerColor = bgColor,
),
shape = RoundedCornerShape(12.dp),
) {
Text(
text = choice,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White,
)
}
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
@Composable
private fun SessionCompleteView(
correct: Int,
total: Int,
onRestart: () -> Unit,
onBack: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Outlined.EmojiEvents,
contentDescription = stringResource(Res.string.result),
modifier = Modifier.size(64.dp),
tint = Color(0xFFFFD54F),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(Res.string.session_completed),
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "$correct/$total",
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
color = when {
correct.toFloat() / total >= 0.8f -> Color(0xFF4CAF50)
correct.toFloat() / total >= 0.5f -> Color(0xFFFF9800)
else -> Color(0xFFF44336)
},
)
Text(
text = stringResource(Res.string.correct_answers),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(32.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Button(onClick = onRestart) {
Text(stringResource(Res.string.retry))
}
Button(onClick = onBack) {
Text(stringResource(Res.string.finish))
}
}
}
}
@@ -0,0 +1,141 @@
package com.notemusicali.ui.eartraining
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.notemusicali.audio.ToneGenerator
import com.notemusicali.audio.createToneGenerator
import com.notemusicali.eartraining.EarTrainingDifficulty
import com.notemusicali.eartraining.EarTrainingEngine
import com.notemusicali.eartraining.EarTrainingMode
import com.notemusicali.eartraining.EarTrainingQuestion
import com.notemusicali.eartraining.EarTrainingSession
import com.notemusicali.eartraining.EarTrainingStatsRepository
import com.notemusicali.goals.DailyGoalManager
import com.notemusicali.util.currentTimeMillis
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class EarTrainingUiState(
val mode: EarTrainingMode = EarTrainingMode.NOTE,
val difficulty: EarTrainingDifficulty = EarTrainingDifficulty.EASY,
val currentQuestion: EarTrainingQuestion? = null,
val questionNumber: Int = 0,
val totalQuestions: Int = 10,
val correctCount: Int = 0,
val selectedAnswer: String? = null,
val isCorrect: Boolean? = null,
val isSessionComplete: Boolean = false,
val isPlaying: Boolean = false,
)
class EarTrainingViewModel : ViewModel() {
private val toneGenerator: ToneGenerator = createToneGenerator()
private val _uiState = MutableStateFlow(EarTrainingUiState())
val uiState: StateFlow<EarTrainingUiState> = _uiState.asStateFlow()
private val sessionStartTime = currentTimeMillis()
fun setMode(mode: EarTrainingMode) {
_uiState.value = _uiState.value.copy(mode = mode)
}
fun setDifficulty(difficulty: EarTrainingDifficulty) {
_uiState.value = _uiState.value.copy(difficulty = difficulty)
}
fun startSession() {
_uiState.value = _uiState.value.copy(
questionNumber = 0,
correctCount = 0,
isSessionComplete = false,
selectedAnswer = null,
isCorrect = null,
)
nextQuestion()
}
private fun nextQuestion() {
val state = _uiState.value
if (state.questionNumber >= state.totalQuestions) {
completeSession()
return
}
val question = when (state.mode) {
EarTrainingMode.NOTE -> EarTrainingEngine.generateNoteQuestion(state.difficulty)
EarTrainingMode.INTERVAL -> EarTrainingEngine.generateIntervalQuestion(state.difficulty)
}
_uiState.value = state.copy(
currentQuestion = question,
questionNumber = state.questionNumber + 1,
selectedAnswer = null,
isCorrect = null,
)
}
fun playSound() {
val question = _uiState.value.currentQuestion ?: return
_uiState.value = _uiState.value.copy(isPlaying = true)
viewModelScope.launch {
when (question.mode) {
EarTrainingMode.NOTE -> {
toneGenerator.playTone(question.targetFrequency, 1500L)
}
EarTrainingMode.INTERVAL -> {
toneGenerator.playTone(question.referenceFrequency, 800L)
delay(1000)
toneGenerator.playTone(question.targetFrequency, 800L)
}
}
delay(1000)
_uiState.value = _uiState.value.copy(isPlaying = false)
}
}
fun submitAnswer(answer: String) {
val state = _uiState.value
val question = state.currentQuestion ?: return
if (state.selectedAnswer != null) return // already answered
val correct = answer == question.correctAnswer
_uiState.value = state.copy(
selectedAnswer = answer,
isCorrect = correct,
correctCount = if (correct) state.correctCount + 1 else state.correctCount,
)
// Auto-advance after delay
viewModelScope.launch {
delay(1500)
nextQuestion()
}
}
private fun completeSession() {
val state = _uiState.value
_uiState.value = state.copy(isSessionComplete = true)
// Save stats
val session = EarTrainingSession(
id = sessionStartTime.toString(),
dateMillis = sessionStartTime,
mode = state.mode.name,
difficulty = state.difficulty.name,
totalQuestions = state.totalQuestions,
correctAnswers = state.correctCount,
)
EarTrainingStatsRepository.save(session)
DailyGoalManager.recordPractice(currentTimeMillis() - sessionStartTime)
}
override fun onCleared() {
super.onCleared()
toneGenerator.release()
}
}
@@ -0,0 +1,135 @@
package com.notemusicali.ui.exercises
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.ui.Alignment
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.exercises.Exercise
import com.notemusicali.exercises.ExerciseRepository
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import com.notemusicali.music.NoteSequence
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.ui.components.GradientCard
import com.notemusicali.ui.components.StaffPreview
import com.notemusicali.ui.theme.CardGradients
private val StarGold = Color(0xFFFFD54F)
private const val MAX_STARS = 4
@Composable
fun ExercisesScreen(
onBack: () -> Unit,
onExerciseSelected: (NoteSequence) -> Unit,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Column(modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight()) {
BackTopBar(
title = stringResource(Res.string.exercises_title),
onBack = onBack,
contentColor = MaterialTheme.colorScheme.primary,
)
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
) {
items(ExerciseRepository.all) { exercise ->
ExerciseCard(
exercise = exercise,
onClick = { onExerciseSelected(exercise.sequence) },
)
}
}
}
}
}
@Composable
private fun ExerciseCard(
exercise: Exercise,
onClick: () -> Unit,
) {
GradientCard(
gradient = CardGradients.forLevel(exercise.level),
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = exercise.title,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
StarRating(level = exercise.level)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = exercise.description,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.7f),
)
Spacer(modifier = Modifier.height(8.dp))
StaffPreview(
notes = exercise.sequence.notes,
beats = exercise.sequence.beats,
beatType = exercise.sequence.beatType,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(Res.string.n_notes, exercise.sequence.size),
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.5f),
)
}
}
@Composable
private fun StarRating(level: Int) {
Row {
repeat(MAX_STARS) { index ->
Icon(
imageVector = if (index < level) Icons.Filled.Star else Icons.Outlined.StarOutline,
contentDescription = stringResource(Res.string.level_n, index + 1),
modifier = Modifier.size(20.dp),
tint = if (index < level) StarGold else Color.White.copy(alpha = 0.3f),
)
}
}
}
@@ -0,0 +1,209 @@
package com.notemusicali.ui.goals
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.goals.DailyGoalManager
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DailyGoalScreen(onBack: () -> Unit) {
var settings by remember { mutableStateOf(DailyGoalManager.getSettings()) }
val state = remember { DailyGoalManager.getState() }
val progress = remember { DailyGoalManager.todayProgressFraction() }
val goalMet = remember { DailyGoalManager.isTodayGoalMet() }
val history = remember { DailyGoalManager.recentHistory(30) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.widthIn(max = 600.dp)) {
BackTopBar(title = stringResource(Res.string.goals_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(16.dp))
// Streak display
if (state.streak > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Text("\uD83D\uDD25", fontSize = 40.sp) // fire emoji
Text(
text = "${state.streak}",
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFFF9800),
modifier = Modifier.padding(start = 8.dp),
)
}
Text(
text = if (state.streak == 1) stringResource(Res.string.consecutive_day_one) else stringResource(Res.string.consecutive_days),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.6f),
)
} else {
Text(
stringResource(Res.string.start_streak),
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.7f),
)
}
Spacer(modifier = Modifier.height(32.dp))
// Circular progress for today
Box(contentAlignment = Alignment.Center) {
CircularProgressIndicator(
progress = { progress },
modifier = Modifier.size(160.dp),
color = if (goalMet) Color(0xFF4CAF50) else MaterialTheme.colorScheme.primary,
trackColor = Color.White.copy(alpha = 0.1f),
strokeWidth = 12.dp,
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val todayMin = (state.todayMs / 60_000).toInt()
Text(
text = "${todayMin}m",
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Text(
text = stringResource(Res.string.of_target, settings.targetMinutes),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.5f),
)
if (goalMet) {
Text(
text = stringResource(Res.string.goal_reached),
fontSize = 12.sp,
color = Color(0xFF4CAF50),
fontWeight = FontWeight.SemiBold,
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// Goal setting
Text(stringResource(Res.string.daily_goal), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(5, 10, 15, 30).forEach { min ->
FilterChip(
selected = settings.targetMinutes == min,
onClick = {
settings = settings.copy(targetMinutes = min)
DailyGoalManager.saveSettings(settings)
},
label = { Text("${min}m") },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Mini calendar (last 30 days)
Text(stringResource(Res.string.last_30_days), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(modifier = Modifier.height(12.dp))
if (history.isEmpty()) {
Text(
stringResource(Res.string.no_recent_activity),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.4f),
)
} else {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
history.forEach { (date, minutes) ->
val met = minutes >= settings.targetMinutes
val intensity = (minutes.toFloat() / settings.targetMinutes).coerceAtMost(1f)
val bgColor = if (met) Color(0xFF4CAF50)
else if (minutes > 0) Color(0xFF4CAF50).copy(alpha = intensity * 0.5f)
else Color.White.copy(alpha = 0.05f)
Box(
modifier = Modifier
.size(28.dp)
.clip(RoundedCornerShape(4.dp))
.background(bgColor)
.then(
if (met) Modifier.border(1.dp, Color(0xFF4CAF50).copy(alpha = 0.5f), RoundedCornerShape(4.dp))
else Modifier
),
contentAlignment = Alignment.Center,
) {
// Show day number
val day = date.takeLast(2)
Text(
text = day,
fontSize = 8.sp,
color = Color.White.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@@ -0,0 +1,287 @@
package com.notemusicali.ui.guide
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Email
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun GuideScreen(onBack: () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.widthIn(max = 600.dp)) {
BackTopBar(title = stringResource(Res.string.guide_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) {
// --- Strumenti ---
GuideSection(stringResource(Res.string.section_tools))
GuideItem(
stringResource(Res.string.tuner_title),
stringResource(Res.string.guide_tuner_desc),
)
GuideItem(
stringResource(Res.string.metronome_title),
stringResource(Res.string.guide_metronome_desc),
)
GuideItem(
stringResource(Res.string.reference_title),
stringResource(Res.string.guide_reference_desc),
)
Spacer(modifier = Modifier.height(16.dp))
// --- Pratica ---
GuideSection(stringResource(Res.string.section_practice))
GuideItem(
stringResource(Res.string.practice_title),
stringResource(Res.string.guide_practice_desc),
)
GuideItem(
stringResource(Res.string.exercises_title),
stringResource(Res.string.guide_exercises_desc),
)
GuideItem(
stringResource(Res.string.scales_title),
stringResource(Res.string.guide_scales_desc),
)
GuideItem(
stringResource(Res.string.scores_title),
stringResource(Res.string.guide_scores_desc),
)
Spacer(modifier = Modifier.height(16.dp))
// --- Sfida ---
GuideSection(stringResource(Res.string.section_challenge))
GuideItem(
stringResource(Res.string.ear_training_title),
stringResource(Res.string.guide_ear_training_desc),
)
GuideItem(
stringResource(Res.string.challenge_title),
stringResource(Res.string.guide_challenge_desc),
)
GuideItem(
stringResource(Res.string.leaderboard_title),
stringResource(Res.string.guide_leaderboard_desc),
)
Spacer(modifier = Modifier.height(16.dp))
// --- Progressi ---
GuideSection(stringResource(Res.string.section_progress))
GuideItem(
stringResource(Res.string.stats_title),
stringResource(Res.string.guide_stats_desc),
)
GuideItem(
stringResource(Res.string.goals_title),
stringResource(Res.string.guide_goals_desc),
)
GuideItem(
stringResource(Res.string.history_title),
stringResource(Res.string.guide_history_desc),
)
Spacer(modifier = Modifier.height(16.dp))
// --- Suggerimenti ---
GuideSection(stringResource(Res.string.guide_section_tips))
GuideTip(stringResource(Res.string.guide_tip_quiet))
GuideTip(stringResource(Res.string.guide_tip_distance))
GuideTip(stringResource(Res.string.guide_tip_daily))
GuideTip(stringResource(Res.string.guide_tip_scales))
GuideTip(stringResource(Res.string.guide_tip_ear))
Spacer(modifier = Modifier.height(16.dp))
// --- Supporto ---
GuideSection(stringResource(Res.string.guide_section_support))
Text(
text = stringResource(Res.string.guide_support_desc),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.6f),
lineHeight = 20.sp,
)
Spacer(modifier = Modifier.height(12.dp))
val uriHandler = LocalUriHandler.current
val email = stringResource(Res.string.guide_support_email)
Button(
onClick = {
uriHandler.openUri("mailto:$email?subject=InTono%20-%20Feedback")
},
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.15f),
),
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Outlined.Email,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color.White,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(Res.string.guide_support_button),
color = Color.White,
)
}
}
Text(
text = email,
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.4f),
modifier = Modifier.padding(top = 4.dp),
)
Spacer(modifier = Modifier.height(16.dp))
// --- Informazioni ---
GuideSection(stringResource(Res.string.guide_section_info))
Text(
text = stringResource(Res.string.info_version, "1.0"),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(Res.string.info_developer),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(8.dp))
val website = stringResource(Res.string.info_website)
Text(
text = website,
fontSize = 14.sp,
color = Color(0xFF90CAF9),
textDecoration = TextDecoration.Underline,
modifier = Modifier
.clickable { uriHandler.openUri("https://$website") }
.padding(vertical = 4.dp),
)
Spacer(modifier = Modifier.height(8.dp))
val privacyUrl = stringResource(Res.string.info_privacy_url)
Button(
onClick = { uriHandler.openUri(privacyUrl) },
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.15f),
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(Res.string.info_privacy_policy),
color = Color.White,
)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
@Composable
private fun GuideSection(title: String) {
Text(
text = title,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.padding(bottom = 12.dp),
)
}
@Composable
private fun GuideItem(title: String, description: String) {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White.copy(alpha = 0.9f),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.6f),
lineHeight = 20.sp,
)
}
}
@Composable
private fun GuideTip(text: String) {
Text(
text = text,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.6f),
lineHeight = 20.sp,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
)
}
@@ -0,0 +1,349 @@
package com.notemusicali.ui.history
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.challenge.ChallengeRepository
import com.notemusicali.challenge.ChallengeResult
import com.notemusicali.eartraining.EarTrainingSession
import com.notemusicali.eartraining.EarTrainingStatsRepository
import com.notemusicali.history.PracticeSession
import com.notemusicali.history.SessionRepository
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.ui.components.GradientCard
import com.notemusicali.ui.theme.CardAccents
import com.notemusicali.ui.theme.CardGradients
import com.notemusicali.util.formatEpochDate
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
/** Unified history item wrapping different session types. */
private sealed class HistoryItem(val dateMillis: Long) {
class Practice(val session: PracticeSession) : HistoryItem(session.dateMillis)
class EarTraining(val session: EarTrainingSession) : HistoryItem(session.dateMillis)
class Challenge(val result: ChallengeResult) : HistoryItem(result.dateMillis)
}
@Composable
fun HistoryScreen(
onBack: () -> Unit,
onReplay: (PracticeSession) -> Unit,
) {
val allItems = remember {
val practice = SessionRepository.load().map { HistoryItem.Practice(it) }
val earTraining = EarTrainingStatsRepository.load().map { HistoryItem.EarTraining(it) }
val challenges = ChallengeRepository.load().map { HistoryItem.Challenge(it) }
(practice + earTraining + challenges).sortedByDescending { it.dateMillis }
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Column(modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight()) {
BackTopBar(
title = stringResource(Res.string.history_title),
onBack = onBack,
contentColor = MaterialTheme.colorScheme.primary,
)
if (allItems.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.no_sessions),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 16.dp),
) {
items(allItems) { item ->
when (item) {
is HistoryItem.Practice -> PracticeCard(
session = item.session,
onClick = { onReplay(item.session) },
)
is HistoryItem.EarTraining -> EarTrainingCard(
session = item.session,
)
is HistoryItem.Challenge -> ChallengeCard(
result = item.result,
)
}
}
}
}
}
}
}
// ── Practice session card ─────────────────────────────────────────
@Composable
private fun PracticeCard(session: PracticeSession, onClick: () -> Unit) {
GradientCard(
gradient = CardGradients.practice,
onClick = onClick,
accentColor = CardAccents.practice,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = session.sequenceName,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
Text(
text = session.instrumentName,
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.5f),
)
}
TypeBadge(
text = stringResource(Res.string.history_type_practice),
color = CardAccents.practice,
)
}
if (!session.completed) {
Text(
text = stringResource(Res.string.partial),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFFFFB74D),
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
val noteLabel = if (session.completed) {
"${session.noteResults.size} note"
} else {
"${session.noteResults.size}/${session.noteMidiNumbers.size} note"
}
InfoChip(label = noteLabel)
InfoChip(label = "~${session.averageCents.toInt()} cents")
InfoChip(label = "${(session.firstAttemptRate * 100).toInt()}% 1\u00B0")
val totalSec = session.totalTimeMs / 1000
InfoChip(label = "${totalSec / 60}m ${totalSec % 60}s")
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatEpochDate(session.dateMillis),
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.4f),
)
}
}
// ── Ear Training session card ─────────────────────────────────────
@Composable
private fun EarTrainingCard(session: EarTrainingSession) {
val modeLabel = when (session.mode) {
"NOTE" -> stringResource(Res.string.history_ear_note)
else -> stringResource(Res.string.history_ear_interval)
}
val diffLabel = when (session.difficulty) {
"EASY" -> "Easy"
"MEDIUM" -> "Medium"
else -> "Hard"
}
val pct = (session.accuracy * 100).toInt()
val scoreColor = when {
pct >= 80 -> Color(0xFF4CAF50)
pct >= 50 -> Color(0xFFFFA726)
else -> Color(0xFFEF5350)
}
GradientCard(
gradient = CardGradients.earTraining,
onClick = {},
accentColor = CardAccents.earTraining,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "$modeLabel — $diffLabel",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
}
TypeBadge(
text = stringResource(Res.string.history_type_ear_training),
color = CardAccents.earTraining,
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
InfoChip(
label = "${session.correctAnswers}/${session.totalQuestions} ${stringResource(Res.string.history_correct)}",
)
Text(
text = "$pct%",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = scoreColor,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatEpochDate(session.dateMillis),
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.4f),
)
}
}
// ── Challenge result card ─────────────────────────────────────────
@Composable
private fun ChallengeCard(result: ChallengeResult) {
GradientCard(
gradient = CardGradients.challenge,
onClick = {},
accentColor = CardAccents.challenge,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = result.sequenceName,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
Text(
text = "${result.timeLimitSec}s",
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.5f),
)
}
TypeBadge(
text = stringResource(Res.string.history_type_challenge),
color = CardAccents.challenge,
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
InfoChip(label = "${result.notesPlayed} note")
InfoChip(label = "${result.maxCombo}x ${stringResource(Res.string.history_combo)}")
Text(
text = "${result.totalScore} pt",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = CardAccents.challenge,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatEpochDate(result.dateMillis),
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.4f),
)
}
}
// ── Shared UI pieces ──────────────────────────────────────────────
@Composable
private fun TypeBadge(text: String, color: Color) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(color.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(
text = text,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
color = color,
)
}
}
@Composable
private fun InfoChip(label: String) {
Text(
text = label,
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.7f),
)
}
@@ -0,0 +1,344 @@
package com.notemusicali.ui.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.List
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material.icons.outlined.Flag
import androidx.compose.material.icons.outlined.Hearing
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Leaderboard
import androidx.compose.material.icons.outlined.LibraryMusic
import androidx.compose.material.icons.outlined.MusicNote
import androidx.compose.material.icons.outlined.Piano
import androidx.compose.material.icons.outlined.School
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material.icons.outlined.SpeakerPhone
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.ui.components.GradientCard
import com.notemusicali.ui.theme.AppColors
import com.notemusicali.ui.theme.CardAccents
import com.notemusicali.ui.theme.CardGradients
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun HomeScreen(
onNavigateToTuner: () -> Unit,
onNavigateToPractice: () -> Unit,
onNavigateToExercises: () -> Unit,
onNavigateToHistory: () -> Unit,
onNavigateToScores: () -> Unit = {},
onNavigateToMetronome: () -> Unit = {},
onNavigateToReference: () -> Unit = {},
onNavigateToScales: () -> Unit = {},
onNavigateToEarTraining: () -> Unit = {},
onNavigateToChallenge: () -> Unit = {},
onNavigateToLeaderboard: () -> Unit = {},
onNavigateToStats: () -> Unit = {},
onNavigateToDailyGoals: () -> Unit = {},
onNavigateToGuide: () -> Unit = {},
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.widthIn(max = 600.dp)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Logo: "In" white + "Tono" gold (like Remotion video)
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = Color.White)) {
append("In")
}
withStyle(SpanStyle(color = AppColors.accent)) {
append("Tono")
}
},
fontSize = 42.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = (-1).sp,
)
Text(
text = stringResource(Res.string.app_subtitle),
fontSize = 14.sp,
color = AppColors.textMuted,
letterSpacing = 2.sp,
)
Spacer(modifier = Modifier.height(8.dp))
// Gold decorative line
Box(
modifier = Modifier
.width(80.dp)
.height(2.dp)
.background(
Brush.horizontalGradient(
listOf(Color.Transparent, AppColors.accent, Color.Transparent),
),
),
)
Spacer(modifier = Modifier.height(32.dp))
// --- Strumenti ---
SectionHeader(stringResource(Res.string.section_tools))
HomeCard(
icon = Icons.Outlined.MusicNote,
title = stringResource(Res.string.tuner_title),
description = stringResource(Res.string.tuner_desc),
gradient = CardGradients.tuner,
accentColor = CardAccents.tuner,
onClick = onNavigateToTuner,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.Speed,
title = stringResource(Res.string.metronome_title),
description = stringResource(Res.string.metronome_desc),
gradient = CardGradients.metronome,
accentColor = CardAccents.metronome,
onClick = onNavigateToMetronome,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.SpeakerPhone,
title = stringResource(Res.string.reference_title),
description = stringResource(Res.string.reference_desc),
gradient = CardGradients.reference,
accentColor = CardAccents.reference,
onClick = onNavigateToReference,
)
Spacer(modifier = Modifier.height(24.dp))
// --- Pratica ---
SectionHeader(stringResource(Res.string.section_practice))
HomeCard(
icon = Icons.Outlined.School,
title = stringResource(Res.string.practice_title),
description = stringResource(Res.string.practice_desc),
gradient = CardGradients.practice,
accentColor = CardAccents.practice,
onClick = onNavigateToPractice,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.AutoMirrored.Outlined.List,
title = stringResource(Res.string.exercises_title),
description = stringResource(Res.string.exercises_desc),
gradient = CardGradients.exercises,
accentColor = CardAccents.exercises,
onClick = onNavigateToExercises,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.Piano,
title = stringResource(Res.string.scales_title),
description = stringResource(Res.string.scales_desc),
gradient = CardGradients.scales,
accentColor = CardAccents.scales,
onClick = onNavigateToScales,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.LibraryMusic,
title = stringResource(Res.string.scores_title),
description = stringResource(Res.string.scores_desc),
gradient = CardGradients.scores,
accentColor = CardAccents.scores,
onClick = onNavigateToScores,
)
Spacer(modifier = Modifier.height(24.dp))
// --- Sfida ---
SectionHeader(stringResource(Res.string.section_challenge))
HomeCard(
icon = Icons.Outlined.Hearing,
title = stringResource(Res.string.ear_training_title),
description = stringResource(Res.string.ear_training_desc),
gradient = CardGradients.earTraining,
accentColor = CardAccents.earTraining,
onClick = onNavigateToEarTraining,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.EmojiEvents,
title = stringResource(Res.string.challenge_title),
description = stringResource(Res.string.challenge_desc),
gradient = CardGradients.challenge,
accentColor = CardAccents.challenge,
onClick = onNavigateToChallenge,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.Leaderboard,
title = stringResource(Res.string.leaderboard_title),
description = stringResource(Res.string.leaderboard_desc),
gradient = CardGradients.leaderboard,
accentColor = CardAccents.leaderboard,
onClick = onNavigateToLeaderboard,
)
Spacer(modifier = Modifier.height(24.dp))
// --- Progressi ---
SectionHeader(stringResource(Res.string.section_progress))
HomeCard(
icon = Icons.Outlined.BarChart,
title = stringResource(Res.string.stats_title),
description = stringResource(Res.string.stats_desc),
gradient = CardGradients.stats,
accentColor = CardAccents.stats,
onClick = onNavigateToStats,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.Flag,
title = stringResource(Res.string.goals_title),
description = stringResource(Res.string.goals_desc),
gradient = CardGradients.goals,
accentColor = CardAccents.goals,
onClick = onNavigateToDailyGoals,
)
Spacer(modifier = Modifier.height(10.dp))
HomeCard(
icon = Icons.Outlined.History,
title = stringResource(Res.string.history_title),
description = stringResource(Res.string.history_desc),
gradient = CardGradients.history,
accentColor = CardAccents.history,
onClick = onNavigateToHistory,
)
Spacer(modifier = Modifier.height(24.dp))
// --- Guida ---
HomeCard(
icon = Icons.AutoMirrored.Outlined.HelpOutline,
title = stringResource(Res.string.guide_title),
description = stringResource(Res.string.guide_desc),
gradient = CardGradients.guide,
accentColor = CardAccents.guide,
onClick = onNavigateToGuide,
)
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title.uppercase(),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = AppColors.accent.copy(alpha = 0.6f),
letterSpacing = 3.sp,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
)
}
@Composable
private fun HomeCard(
icon: ImageVector,
title: String,
description: String,
gradient: Brush,
accentColor: Color,
onClick: () -> Unit,
) {
GradientCard(
gradient = gradient,
onClick = onClick,
accentColor = accentColor,
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = title,
modifier = Modifier.size(36.dp),
tint = accentColor.copy(alpha = 0.9f),
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = title,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = description,
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.55f),
)
}
}
}
}
@@ -0,0 +1,369 @@
package com.notemusicali.ui.metronome
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.notemusicali.metronome.TimeSignature
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.ui.components.KeepScreenOn
import com.notemusicali.ui.theme.TunerColors
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun MetronomeScreen(
onBack: () -> Unit,
viewModel: MetronomeViewModel = viewModel { MetronomeViewModel() },
) {
val state by viewModel.uiState.collectAsState()
if (state.isPlaying || state.tunerActive) {
KeepScreenOn()
}
DisposableEffect(Unit) {
onDispose { viewModel.stopTuner() }
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier.widthIn(max = 600.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BackTopBar(title = stringResource(Res.string.metronome_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// BPM display
Text(
text = "${state.bpm}",
fontSize = 96.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Text(
text = stringResource(Res.string.bpm),
fontSize = 20.sp,
color = Color.White.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(24.dp))
// BPM slider
Slider(
value = state.bpm.toFloat(),
onValueChange = { viewModel.setBpm(it.toInt()) },
valueRange = 30f..240f,
modifier = Modifier.fillMaxWidth(),
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = Color.White.copy(alpha = 0.15f),
),
)
Spacer(modifier = Modifier.height(24.dp))
// Time signature
Text(stringResource(Res.string.time_signature), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TimeSignature.entries.forEach { ts ->
FilterChip(
selected = state.timeSignature == ts,
onClick = { viewModel.setTimeSignature(ts) },
label = { Text(ts.displayName) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Beat indicators
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(vertical = 16.dp),
) {
repeat(state.timeSignature.beatsPerMeasure) { beat ->
val isActive = state.isPlaying && state.currentBeat == beat
val isAccent = beat == 0
Box(
modifier = Modifier
.size(if (isAccent) 28.dp else 22.dp)
.clip(CircleShape)
.background(
when {
isActive && isAccent -> Color(0xFFFF9800)
isActive -> MaterialTheme.colorScheme.primary
else -> Color.White.copy(alpha = 0.15f)
}
),
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Play/Stop + Tap Tempo row
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = { viewModel.tapTempo() },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.1f),
),
) {
Text(stringResource(Res.string.tap_tempo), color = Color.White)
}
IconButton(
onClick = { viewModel.togglePlayStop() },
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(
if (state.isPlaying) Color(0xFFF44336).copy(alpha = 0.3f)
else MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
),
) {
Icon(
imageVector = if (state.isPlaying) Icons.Filled.Stop else Icons.Filled.PlayArrow,
contentDescription = if (state.isPlaying) "Stop" else "Play",
tint = Color.White,
modifier = Modifier.size(40.dp),
)
}
// Tuner toggle
IconButton(
onClick = {
if (state.tunerActive) viewModel.stopTuner() else viewModel.startTuner()
},
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
if (state.tunerActive) Color(0xFF4CAF50).copy(alpha = 0.3f)
else Color.White.copy(alpha = 0.1f)
),
) {
Icon(
imageVector = if (state.tunerActive) Icons.Filled.Mic else Icons.Filled.MicOff,
contentDescription = "Tuner",
tint = if (state.tunerActive) Color(0xFF4CAF50) else Color.White.copy(alpha = 0.5f),
modifier = Modifier.size(24.dp),
)
}
}
Spacer(modifier = Modifier.height(20.dp))
// Tuner display
if (state.tunerActive) {
val tunerBgColor by animateColorAsState(
targetValue = when (state.tuningDisplay) {
TuningDisplay.IN_TUNE -> TunerColors.inTuneBackground
TuningDisplay.OUT_OF_TUNE -> TunerColors.outOfTuneBackground
TuningDisplay.SILENCE -> Color.White.copy(alpha = 0.04f)
},
animationSpec = tween(300),
label = "tunerBg",
)
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(tunerBgColor)
.padding(vertical = 12.dp, horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Note name + octave
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.Center,
) {
Text(
text = state.noteName.ifEmpty { "---" },
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
if (state.noteName.isNotEmpty()) {
Text(
text = "${state.octave}",
fontSize = 20.sp,
color = Color.White.copy(alpha = 0.6f),
modifier = Modifier.padding(bottom = 8.dp, start = 4.dp),
)
}
}
// Cents gauge
val isActive = state.tuningDisplay != TuningDisplay.SILENCE
CentsGauge(
cents = state.centsDeviation,
isActive = isActive,
modifier = Modifier
.fillMaxWidth()
.height(40.dp),
)
// Frequency + cents text
if (state.frequency > 0f) {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(top = 4.dp),
) {
Text(
text = "${(state.frequency * 10).toInt() / 10.0} Hz",
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.5f),
)
if (isActive) {
val sign = if (state.centsDeviation >= 0) "+" else ""
Text(
text = " · ${sign}${state.centsDeviation.toInt()} cents",
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.5f),
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
@Composable
private fun CentsGauge(
cents: Float,
isActive: Boolean,
modifier: Modifier = Modifier,
) {
val indicatorColor by animateColorAsState(
targetValue = when {
!isActive -> TunerColors.silence
kotlin.math.abs(cents) <= 15f -> TunerColors.inTune
else -> TunerColors.outOfTune
},
animationSpec = tween(200),
label = "gaugeColor",
)
Canvas(modifier = modifier) {
val centerX = size.width / 2f
val centerY = size.height / 2f
val gaugeWidth = size.width * 0.85f
val gaugeLeft = centerX - gaugeWidth / 2f
// Background track
drawLine(
color = Color.White.copy(alpha = 0.15f),
start = Offset(gaugeLeft, centerY),
end = Offset(gaugeLeft + gaugeWidth, centerY),
strokeWidth = 6f,
cap = StrokeCap.Round,
)
// In-tune zone
val zoneWidth = gaugeWidth * 15f / 50f
drawLine(
color = TunerColors.inTune.copy(alpha = 0.2f),
start = Offset(centerX - zoneWidth, centerY),
end = Offset(centerX + zoneWidth, centerY),
strokeWidth = 6f,
cap = StrokeCap.Round,
)
// Center tick
drawLine(
color = Color.White.copy(alpha = 0.6f),
start = Offset(centerX, centerY - 14f),
end = Offset(centerX, centerY + 14f),
strokeWidth = 2f,
)
// Indicator
if (isActive) {
val clamped = cents.coerceIn(-50f, 50f)
val indicatorX = centerX + (clamped / 50f) * (gaugeWidth / 2f)
drawCircle(
color = indicatorColor.copy(alpha = 0.3f),
radius = 16f,
center = Offset(indicatorX, centerY),
)
drawCircle(
color = indicatorColor,
radius = 10f,
center = Offset(indicatorX, centerY),
)
}
}
}
@@ -0,0 +1,155 @@
package com.notemusicali.ui.metronome
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.notemusicali.audio.AudioProcessor
import com.notemusicali.metronome.MetronomeEngine
import com.notemusicali.metronome.TimeSignature
import com.notemusicali.util.currentTimeMillis
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.math.abs
enum class TuningDisplay {
SILENCE, IN_TUNE, OUT_OF_TUNE
}
data class MetronomeUiState(
val bpm: Int = 120,
val timeSignature: TimeSignature = TimeSignature.FOUR_FOUR,
val isPlaying: Boolean = false,
val currentBeat: Int = 0,
// Tuner
val noteName: String = "",
val octave: Int = 0,
val frequency: Float = 0f,
val centsDeviation: Float = 0f,
val tuningDisplay: TuningDisplay = TuningDisplay.SILENCE,
val tunerActive: Boolean = false,
)
class MetronomeViewModel : ViewModel() {
private val engine = MetronomeEngine()
private val audioProcessor = AudioProcessor()
private val _uiState = MutableStateFlow(MetronomeUiState())
val uiState: StateFlow<MetronomeUiState> = _uiState.asStateFlow()
// Tap tempo tracking
private val tapTimes = mutableListOf<Long>()
private var silenceJob: Job? = null
private var isListening = false
init {
viewModelScope.launch {
engine.isPlaying.collect { playing ->
_uiState.value = _uiState.value.copy(isPlaying = playing)
}
}
viewModelScope.launch {
engine.currentBeat.collect { beat ->
_uiState.value = _uiState.value.copy(currentBeat = beat)
}
}
}
fun setBpm(bpm: Int) {
val clamped = bpm.coerceIn(30, 240)
engine.bpm = clamped
_uiState.value = _uiState.value.copy(bpm = clamped)
}
fun setTimeSignature(ts: TimeSignature) {
val wasPlaying = engine.isPlaying.value
if (wasPlaying) engine.stop()
engine.timeSignature = ts
_uiState.value = _uiState.value.copy(timeSignature = ts)
if (wasPlaying) engine.start(viewModelScope)
}
fun togglePlayStop() {
if (engine.isPlaying.value) {
engine.stop()
} else {
engine.start(viewModelScope)
}
}
fun tapTempo() {
val now = currentTimeMillis()
tapTimes.add(now)
// Keep only last 5 taps
if (tapTimes.size > 5) tapTimes.removeFirst()
if (tapTimes.size >= 2) {
val intervals = tapTimes.zipWithNext { a, b -> b - a }
val avgInterval = intervals.average()
if (avgInterval > 0) {
val bpm = (60_000.0 / avgInterval).toInt().coerceIn(30, 240)
setBpm(bpm)
}
}
}
fun startTuner() {
if (isListening) return
isListening = true
audioProcessor.start(viewModelScope)
_uiState.value = _uiState.value.copy(tunerActive = true)
viewModelScope.launch {
audioProcessor.result.collect { result ->
when (result) {
is AudioProcessor.ProcessedResult.Detected -> {
silenceJob?.cancel()
silenceJob = null
val nr = result.noteResult
val inTune = abs(nr.centsDeviation) <= 15f
_uiState.value = _uiState.value.copy(
noteName = nr.note.displayName,
octave = nr.note.octave,
frequency = nr.frequency,
centsDeviation = nr.centsDeviation,
tuningDisplay = if (inTune) TuningDisplay.IN_TUNE else TuningDisplay.OUT_OF_TUNE,
)
}
is AudioProcessor.ProcessedResult.Silence,
is AudioProcessor.ProcessedResult.NoNote -> {
if (silenceJob == null && _uiState.value.noteName.isNotEmpty()) {
silenceJob = viewModelScope.launch {
delay(1500L)
_uiState.value = _uiState.value.copy(
noteName = "",
tuningDisplay = TuningDisplay.SILENCE,
)
}
}
}
}
}
}
}
fun stopTuner() {
isListening = false
silenceJob?.cancel()
silenceJob = null
audioProcessor.stop()
_uiState.value = _uiState.value.copy(
tunerActive = false,
noteName = "",
tuningDisplay = TuningDisplay.SILENCE,
)
}
override fun onCleared() {
super.onCleared()
stopTuner()
engine.release()
}
}
@@ -0,0 +1,496 @@
package com.notemusicali.ui.practice
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.MusicNote
import androidx.compose.material.icons.outlined.Piano
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.audio.InstrumentPreset
import com.notemusicali.music.ItalianNotation
import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteDuration
import com.notemusicali.music.NoteSequence
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
private val NoteButtonGradient = Brush.linearGradient(
listOf(Color(0xFF00695C), Color(0xFF00838F)),
)
private val NoteChipGradient = Brush.linearGradient(
listOf(Color(0xFF1565C0), Color(0xFF7B1FA2)),
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ManualInputScreen(
onBack: () -> Unit,
onSequenceReady: (NoteSequence, InstrumentPreset) -> Unit,
) {
val notes = remember { mutableStateListOf<MusicalNote>() }
var selectedInstrument by remember { mutableStateOf(InstrumentPreset.VIOLINO) }
var selectedOctave by remember { mutableIntStateOf(4) }
var useFlats by remember { mutableStateOf(false) }
var selectedDuration by remember { mutableStateOf(NoteDuration.QUARTER) }
val manualSequenceName = stringResource(Res.string.manual_sequence_name)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight(),
) {
BackTopBar(
title = stringResource(Res.string.note_input_title),
onBack = onBack,
contentColor = MaterialTheme.colorScheme.primary,
)
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
// Instrument selector section
SectionHeader(icon = Icons.Outlined.Piano, label = stringResource(Res.string.instrument))
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
InstrumentPreset.entries.forEach { preset ->
FilterChip(
selected = selectedInstrument == preset,
onClick = {
selectedInstrument = preset
if (selectedOctave !in preset.octaveRange) {
selectedOctave = preset.octaveRange.first
.coerceAtLeast(3)
.coerceAtMost(preset.octaveRange.last)
}
},
label = { Text(preset.displayName, fontSize = 13.sp) },
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Octave selector section
SectionHeader(icon = Icons.Outlined.MusicNote, label = stringResource(Res.string.octave))
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
for (oct in selectedInstrument.octaveRange) {
FilterChip(
selected = selectedOctave == oct,
onClick = { selectedOctave = oct },
label = { Text("$oct") },
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Sharp/Flat toggle
Row {
FilterChip(
selected = !useFlats,
onClick = { useFlats = false },
label = { Text("#") },
)
Spacer(modifier = Modifier.width(8.dp))
FilterChip(
selected = useFlats,
onClick = { useFlats = true },
label = { Text("b") },
)
}
Spacer(modifier = Modifier.height(12.dp))
// Duration selector
SectionHeader(icon = Icons.Outlined.MusicNote, label = stringResource(Res.string.duration))
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
NoteDuration.entries.forEach { dur ->
FilterChip(
selected = selectedDuration == dur,
onClick = { selectedDuration = dur },
label = {
NoteDurationIcon(
duration = dur,
color = MaterialTheme.colorScheme.onSurface,
size = 28.dp,
)
},
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Note buttons grid
SectionHeader(icon = Icons.Outlined.Edit, label = stringResource(Res.string.notes))
Spacer(modifier = Modifier.height(4.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ItalianNotation.allNotes().forEachIndexed { index, noteName ->
val displayText = if (useFlats && noteName.flat != null) {
noteName.flat
} else {
noteName.sharp
}
NoteButton(
text = displayText,
onClick = {
notes.add(
MusicalNote.fromNameAndOctave(
index, selectedOctave, selectedDuration,
preferFlat = useFlats && noteName.flat != null,
),
)
},
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Selected notes preview
Text(
text = stringResource(Res.string.selected_notes, notes.size),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
)
if (notes.isNotEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.border(
1.dp,
Color.White.copy(alpha = 0.15f),
RoundedCornerShape(12.dp),
)
.padding(12.dp),
) {
items(notes.toList()) { note ->
val chipShape = RoundedCornerShape(8.dp)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(chipShape)
.background(NoteChipGradient)
.border(1.dp, Color.White.copy(alpha = 0.15f), chipShape)
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
text = note.fullName,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White,
)
NoteDurationIcon(
duration = note.duration,
color = Color.White.copy(alpha = 0.6f),
size = 14.dp,
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = {
if (notes.isNotEmpty()) notes.removeLast()
},
enabled = notes.isNotEmpty(),
) {
Text(stringResource(Res.string.undo_last))
}
OutlinedButton(
onClick = { notes.clear() },
enabled = notes.isNotEmpty(),
) {
Text(stringResource(Res.string.clear_all))
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
onSequenceReady(
NoteSequence(name = manualSequenceName, notes = notes.toList()),
selectedInstrument,
)
},
enabled = notes.size >= 2,
) {
Text(
text = stringResource(Res.string.start_practice_n, notes.size),
fontSize = 14.sp,
)
}
}
}
}
}
}
// ── Note duration icon drawn with Canvas ──────────────────────────
@Composable
private fun NoteDurationIcon(
duration: NoteDuration,
color: Color,
size: Dp,
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier.size(size)) {
val w = this.size.width
val h = this.size.height
when (duration) {
NoteDuration.WHOLE -> drawWholeNote(color, w, h)
NoteDuration.HALF -> drawHalfNote(color, w, h)
NoteDuration.QUARTER -> drawQuarterNote(color, w, h)
NoteDuration.EIGHTH -> drawNoteWithFlags(color, w, h, flags = 1)
NoteDuration.SIXTEENTH -> drawNoteWithFlags(color, w, h, flags = 2)
NoteDuration.THIRTY_SECOND -> drawNoteWithFlags(color, w, h, flags = 3)
}
}
}
/** Whole note: open oval, no stem. */
private fun DrawScope.drawWholeNote(color: Color, w: Float, h: Float) {
val cx = w * 0.5f
val cy = h * 0.55f
val rx = w * 0.30f
val ry = h * 0.18f
rotate(degrees = -15f, pivot = Offset(cx, cy)) {
drawOval(
color = color,
topLeft = Offset(cx - rx, cy - ry),
size = Size(rx * 2, ry * 2),
style = Stroke(width = w * 0.06f),
)
}
}
/** Half note: open oval + stem. */
private fun DrawScope.drawHalfNote(color: Color, w: Float, h: Float) {
val cx = w * 0.42f
val cy = h * 0.72f
val rx = w * 0.22f
val ry = h * 0.13f
// Oval (open)
rotate(degrees = -15f, pivot = Offset(cx, cy)) {
drawOval(
color = color,
topLeft = Offset(cx - rx, cy - ry),
size = Size(rx * 2, ry * 2),
style = Stroke(width = w * 0.06f),
)
}
// Stem
val stemX = cx + rx * 0.92f
drawLine(
color = color,
start = Offset(stemX, cy),
end = Offset(stemX, h * 0.12f),
strokeWidth = w * 0.06f,
cap = StrokeCap.Round,
)
}
/** Quarter note: filled oval + stem. */
private fun DrawScope.drawQuarterNote(color: Color, w: Float, h: Float) {
val cx = w * 0.42f
val cy = h * 0.72f
val rx = w * 0.22f
val ry = h * 0.13f
// Oval (filled)
rotate(degrees = -15f, pivot = Offset(cx, cy)) {
drawOval(
color = color,
topLeft = Offset(cx - rx, cy - ry),
size = Size(rx * 2, ry * 2),
style = Fill,
)
}
// Stem
val stemX = cx + rx * 0.92f
drawLine(
color = color,
start = Offset(stemX, cy),
end = Offset(stemX, h * 0.12f),
strokeWidth = w * 0.06f,
cap = StrokeCap.Round,
)
}
/** Eighth / sixteenth / thirty-second: filled oval + stem + flags. */
private fun DrawScope.drawNoteWithFlags(color: Color, w: Float, h: Float, flags: Int) {
val cx = w * 0.38f
val cy = h * 0.75f
val rx = w * 0.20f
val ry = h * 0.12f
// Oval (filled)
rotate(degrees = -15f, pivot = Offset(cx, cy)) {
drawOval(
color = color,
topLeft = Offset(cx - rx, cy - ry),
size = Size(rx * 2, ry * 2),
style = Fill,
)
}
// Stem
val stemX = cx + rx * 0.92f
val stemTop = h * 0.10f
drawLine(
color = color,
start = Offset(stemX, cy),
end = Offset(stemX, stemTop),
strokeWidth = w * 0.06f,
cap = StrokeCap.Round,
)
// Flags
val flagSpacing = h * 0.13f
for (i in 0 until flags) {
val flagTop = stemTop + i * flagSpacing
val path = Path().apply {
moveTo(stemX, flagTop)
cubicTo(
stemX + w * 0.25f, flagTop + h * 0.06f,
stemX + w * 0.30f, flagTop + h * 0.12f,
stemX + w * 0.10f, flagTop + h * 0.18f,
)
}
drawPath(
path = path,
color = color,
style = Stroke(width = w * 0.06f, cap = StrokeCap.Round),
)
}
}
// ── Other composables ─────────────────────────────────────────────
@Composable
private fun SectionHeader(icon: ImageVector, label: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 4.dp),
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = label,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
)
}
}
@Composable
private fun NoteButton(
text: String,
onClick: () -> Unit,
) {
val shape = RoundedCornerShape(12.dp)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(shape)
.background(NoteButtonGradient)
.border(1.dp, Color.White.copy(alpha = 0.15f), shape)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Text(
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White,
)
}
}
@@ -0,0 +1,513 @@
package com.notemusicali.ui.practice
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.outlined.EmojiEvents
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.notemusicali.history.PracticeSession
import com.notemusicali.metronome.MetronomeEngine
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.ui.components.KeepScreenOn
import com.notemusicali.ui.components.StaffContextView
import com.notemusicali.ui.components.StaffFullView
import com.notemusicali.ui.components.StaffView
import com.notemusicali.ui.theme.TunerColors
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
private enum class StaffMode {
SINGLE,
CONTEXT,
FULL,
}
@Composable
fun PracticeScreen(
onBack: () -> Unit,
viewModel: PracticeViewModel = viewModel { PracticeViewModel() },
onFinished: () -> Unit,
) {
val state by viewModel.uiState.collectAsState()
var staffMode by rememberSaveable { mutableStateOf(StaffMode.SINGLE.name) }
val currentMode = StaffMode.valueOf(staffMode)
var lightStaff by rememberSaveable { mutableStateOf(false) }
var hideNoteName by rememberSaveable { mutableStateOf(false) }
// Integrated metronome
val metronomeEngine = remember { MetronomeEngine() }
var metronomeOn by rememberSaveable { mutableStateOf(false) }
var metronomeBpm by rememberSaveable { mutableIntStateOf(120) }
KeepScreenOn()
DisposableEffect(Unit) {
onDispose {
viewModel.savePartialSession()
viewModel.stopListening()
metronomeEngine.release()
}
}
val backgroundColor by animateColorAsState(
targetValue = when (state.matchState) {
PracticeViewModel.NoteMatchState.CORRECT -> TunerColors.inTuneBackground
PracticeViewModel.NoteMatchState.INCORRECT -> TunerColors.outOfTuneBackground
PracticeViewModel.NoteMatchState.WAITING -> TunerColors.silenceBackground
},
animationSpec = tween(durationMillis = 300),
label = "bgColor",
)
if (state.isCompleted) {
LaunchedEffect(Unit) {
metronomeOn = false
metronomeEngine.stop()
}
CompletedView(
session = state.lastSession,
onSave = { viewModel.saveSession() },
onFinished = onFinished,
onReset = { viewModel.reset() },
)
return
}
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Color.Black.copy(alpha = 0.4f)),
)
),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BackTopBar(title = stringResource(Res.string.practice_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Progress bar
LinearProgressIndicator(
progress = { state.progress },
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
color = TunerColors.inTune,
trackColor = Color.White.copy(alpha = 0.2f),
)
Text(
text = stringResource(Res.string.note_n_of_total, state.currentIndex + 1, state.sequence.size),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.5f),
modifier = Modifier.padding(top = 8.dp),
)
// View mode selector
Row(
modifier = Modifier.padding(top = 8.dp).horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
StaffMode.entries.forEach { mode ->
val modeLabel = when (mode) {
StaffMode.SINGLE -> stringResource(Res.string.view_note)
StaffMode.CONTEXT -> stringResource(Res.string.view_context)
StaffMode.FULL -> stringResource(Res.string.view_score)
}
FilterChip(
selected = currentMode == mode,
onClick = { staffMode = mode.name },
label = { Text(modeLabel, fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = Color.White.copy(alpha = 0.25f),
selectedLabelColor = Color.White,
),
)
}
FilterChip(
selected = lightStaff,
onClick = { lightStaff = !lightStaff },
label = { Text(stringResource(Res.string.view_light), fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = Color.White.copy(alpha = 0.25f),
selectedLabelColor = Color.White,
),
)
FilterChip(
selected = hideNoteName,
onClick = { hideNoteName = !hideNoteName },
label = { Text(stringResource(Res.string.hide_note_name), fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = Color(0xFFFF6B9D).copy(alpha = 0.3f),
selectedLabelColor = Color(0xFFFF6B9D),
),
)
}
// Mini metronome bar
val scope = rememberCoroutineScope()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.06f))
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
metronomeOn = !metronomeOn
if (metronomeOn) {
metronomeEngine.bpm = metronomeBpm
metronomeEngine.start(scope)
} else {
metronomeEngine.stop()
}
},
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = if (metronomeOn) Icons.Filled.Stop else Icons.Filled.PlayArrow,
contentDescription = null,
tint = if (metronomeOn) Color(0xFFFFD700) else Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(22.dp),
)
}
Text(
text = "$metronomeBpm",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = if (metronomeOn) Color(0xFFFFD700) else Color.White.copy(alpha = 0.6f),
modifier = Modifier.widthIn(min = 32.dp),
)
Slider(
value = metronomeBpm.toFloat(),
onValueChange = {
metronomeBpm = it.toInt()
metronomeEngine.bpm = metronomeBpm
},
valueRange = 30f..240f,
modifier = Modifier.weight(1f).height(24.dp),
colors = SliderDefaults.colors(
thumbColor = if (metronomeOn) Color(0xFFFFD700) else Color.White.copy(alpha = 0.4f),
activeTrackColor = if (metronomeOn) Color(0xFFFFD700).copy(alpha = 0.5f) else Color.White.copy(alpha = 0.2f),
inactiveTrackColor = Color.White.copy(alpha = 0.1f),
),
)
Text(
text = "BPM",
fontSize = 10.sp,
color = Color.White.copy(alpha = 0.3f),
modifier = Modifier.padding(start = 4.dp),
)
}
Spacer(modifier = Modifier.weight(1f))
// Staff display based on mode
val staffBgMod = if (lightStaff) {
Modifier.clip(RoundedCornerShape(12.dp)).background(Color.White)
} else Modifier
when (currentMode) {
StaffMode.SINGLE -> {
Text(
text = stringResource(Res.string.play_note),
fontSize = 20.sp,
color = Color.White.copy(alpha = 0.6f),
)
state.targetNote?.let { note ->
StaffView(
note = note,
modifier = Modifier.padding(horizontal = 16.dp).then(staffBgMod),
lightTheme = lightStaff,
)
}
}
StaffMode.CONTEXT -> {
StaffContextView(
notes = state.sequence.notes,
currentIndex = state.currentIndex,
modifier = Modifier.padding(horizontal = 8.dp).then(staffBgMod),
lightTheme = lightStaff,
beats = state.sequence.beats,
beatType = state.sequence.beatType,
)
}
StaffMode.FULL -> {
StaffFullView(
notes = state.sequence.notes,
currentIndex = state.currentIndex,
modifier = staffBgMod,
lightTheme = lightStaff,
beats = state.sequence.beats,
beatType = state.sequence.beatType,
)
}
}
// Note name with glow card
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(top = 16.dp)
.clip(RoundedCornerShape(20.dp))
.background(
if (hideNoteName) Color.White.copy(alpha = 0.04f)
else Color.White.copy(alpha = 0.08f)
)
.padding(horizontal = 40.dp, vertical = 8.dp),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (hideNoteName) "?" else (state.targetNote?.displayName ?: ""),
fontSize = if (currentMode == StaffMode.SINGLE) 96.sp else 64.sp,
fontWeight = FontWeight.Bold,
color = if (hideNoteName) Color.White.copy(alpha = 0.15f) else Color.White,
textAlign = TextAlign.Center,
)
Text(
text = if (hideNoteName) "" else stringResource(Res.string.octave_n, (state.targetNote?.octave ?: "").toString()),
fontSize = if (currentMode == StaffMode.SINGLE) 20.sp else 16.sp,
color = Color.White.copy(alpha = 0.6f),
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Detected note card — always reserve space to prevent layout jumps
val hasDetection = state.detectedNoteName.isNotEmpty()
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(
if (hasDetection) Color.White.copy(alpha = 0.06f)
else Color.Transparent
)
.padding(horizontal = 32.dp, vertical = 12.dp),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (hasDetection) stringResource(Res.string.detected) else "",
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.4f),
)
Text(
text = if (hasDetection) state.detectedNoteName else " ",
fontSize = 48.sp,
fontWeight = FontWeight.Medium,
color = if (hasDetection) {
when (state.matchState) {
PracticeViewModel.NoteMatchState.CORRECT -> TunerColors.inTune
PracticeViewModel.NoteMatchState.INCORRECT -> TunerColors.outOfTune
PracticeViewModel.NoteMatchState.WAITING -> Color.White
}
} else Color.Transparent,
)
val sign = if (state.detectedCents >= 0) "+" else ""
Text(
text = if (hasDetection) "${sign}${state.detectedCents.toInt()} cents" else "",
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.4f),
)
}
}
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun CompletedView(
session: PracticeSession?,
onSave: () -> Unit,
onFinished: () -> Unit,
onReset: () -> Unit,
) {
LaunchedEffect(Unit) {
onSave()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0xFF1B5E20), Color(0xFF0A280E)),
)
),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
imageVector = Icons.Outlined.EmojiEvents,
contentDescription = stringResource(Res.string.completed),
modifier = Modifier.size(72.dp),
tint = Color(0xFFFFD54F),
)
Text(
text = stringResource(Res.string.completed),
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Text(
text = stringResource(Res.string.all_notes_correct),
fontSize = 18.sp,
color = Color.White.copy(alpha = 0.7f),
)
// Session stats
if (session != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color.White.copy(alpha = 0.1f))
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
StatRow(
label = stringResource(Res.string.avg_deviation),
value = "${session.averageCents.toInt()} cents",
)
StatRow(
label = stringResource(Res.string.perfect_notes),
value = "${session.perfectNotes}/${session.noteResults.size}",
)
StatRow(
label = stringResource(Res.string.first_attempt),
value = "${(session.firstAttemptRate * 100).toInt()}%",
)
val totalSec = session.totalTimeMs / 1000
StatRow(
label = stringResource(Res.string.duration),
value = "${totalSec / 60}m ${totalSec % 60}s",
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Button(onClick = onReset) {
Text(stringResource(Res.string.repeat))
}
Button(onClick = onFinished) {
Text(stringResource(Res.string.finish))
}
}
}
}
}
@Composable
private fun StatRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = label,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.6f),
)
Text(
text = value,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
}
}
@@ -0,0 +1,155 @@
package com.notemusicali.ui.practice
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.FileOpen
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.music.MusicXmlParser
import com.notemusicali.music.NoteSequence
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.ui.components.GradientCard
import com.notemusicali.ui.components.rememberFilePickerLauncher
import com.notemusicali.ui.theme.CardGradients
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun PracticeSetupScreen(
onBack: () -> Unit,
onManualInput: () -> Unit,
onMusicXmlLoaded: (NoteSequence) -> Unit,
onScan: () -> Unit = {},
) {
val filePickerLauncher = rememberFilePickerLauncher(
mimeTypes = listOf("text/xml", "application/xml", "*/*"),
onFileContent = { content ->
try {
val sequence = MusicXmlParser.parse(content, "MusicXML Import")
if (sequence.notes.isNotEmpty()) {
onMusicXmlLoaded(sequence)
}
} catch (_: Exception) {
// Parse failed
}
},
)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight(),
) {
BackTopBar(
title = stringResource(Res.string.practice_title),
onBack = onBack,
contentColor = MaterialTheme.colorScheme.primary,
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(Res.string.practice_choose),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(32.dp))
SetupCard(
icon = Icons.Outlined.Edit,
title = stringResource(Res.string.manual_input_title),
description = stringResource(Res.string.manual_input_desc),
onClick = onManualInput,
)
Spacer(modifier = Modifier.height(16.dp))
SetupCard(
icon = Icons.Outlined.FileOpen,
title = stringResource(Res.string.import_musicxml_title),
description = stringResource(Res.string.import_musicxml_desc),
onClick = filePickerLauncher,
)
Spacer(modifier = Modifier.height(16.dp))
SetupCard(
icon = Icons.Outlined.CameraAlt,
title = stringResource(Res.string.scan_title),
description = stringResource(Res.string.scan_desc),
onClick = onScan,
)
}
}
}
}
@Composable
private fun SetupCard(
icon: ImageVector,
title: String,
description: String,
onClick: () -> Unit,
) {
GradientCard(
gradient = CardGradients.setup,
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = title,
modifier = Modifier.size(36.dp),
tint = Color.White.copy(alpha = 0.8f),
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = title,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.7f),
)
}
}
}
}
@@ -0,0 +1,231 @@
package com.notemusicali.ui.practice
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.notemusicali.audio.AudioProcessor
import com.notemusicali.audio.InstrumentPreset
import com.notemusicali.goals.DailyGoalManager
import com.notemusicali.history.NoteResult
import com.notemusicali.history.PracticeSession
import com.notemusicali.history.SessionRepository
import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteSequence
import com.notemusicali.util.currentTimeMillis
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.math.abs
class PracticeViewModel : ViewModel() {
private val audioProcessor = AudioProcessor()
enum class NoteMatchState {
WAITING,
CORRECT,
INCORRECT,
}
data class PracticeUiState(
val sequence: NoteSequence = NoteSequence.empty(),
val currentIndex: Int = 0,
val targetNote: MusicalNote? = null,
val detectedNoteName: String = "",
val detectedCents: Float = 0f,
val matchState: NoteMatchState = NoteMatchState.WAITING,
val isCompleted: Boolean = false,
val progress: Float = 0f,
val lastSession: PracticeSession? = null,
)
private val _uiState = MutableStateFlow(PracticeUiState())
val uiState: StateFlow<PracticeUiState> = _uiState.asStateFlow()
private var correctHoldJob: Job? = null
private var isListening = false
// Per-note tracking
private var sessionStartTime: Long = 0L
private var noteStartTime: Long = 0L
private var firstAttemptForNote: Boolean = true
private var lastCentsForNote: Float = 0f
private val collectedResults = mutableListOf<NoteResult>()
private var currentInstrument: InstrumentPreset = InstrumentPreset.VIOLINO
fun selectInstrument(preset: InstrumentPreset) {
val wasListening = isListening
if (wasListening) stopListening()
currentInstrument = preset
audioProcessor.updatePreset(preset)
if (wasListening) startListening()
}
fun setSequence(sequence: NoteSequence) {
stopListening()
collectedResults.clear()
sessionStartTime = currentTimeMillis()
noteStartTime = sessionStartTime
firstAttemptForNote = true
lastCentsForNote = 0f
_uiState.value = PracticeUiState(
sequence = sequence,
targetNote = sequence.noteAt(0),
progress = if (sequence.size > 0) 0f else 1f,
)
}
fun startListening() {
if (isListening) return
isListening = true
audioProcessor.start(viewModelScope)
viewModelScope.launch {
audioProcessor.result.collect { result ->
val state = _uiState.value
if (state.isCompleted) return@collect
when (result) {
is AudioProcessor.ProcessedResult.Detected -> {
val detected = result.noteResult
val target = state.targetNote ?: return@collect
val isCorrect = detected.note.noteIndex == target.noteIndex &&
detected.note.octave == target.octave &&
abs(detected.centsDeviation) <= 30f
_uiState.value = state.copy(
detectedNoteName = detected.note.displayName,
detectedCents = detected.centsDeviation,
matchState = if (isCorrect) NoteMatchState.CORRECT else NoteMatchState.INCORRECT,
)
if (isCorrect && correctHoldJob == null) {
lastCentsForNote = detected.centsDeviation
val holdTime = target.duration.holdMs
correctHoldJob = viewModelScope.launch {
delay(holdTime)
advanceToNext()
}
} else if (!isCorrect) {
correctHoldJob?.cancel()
correctHoldJob = null
firstAttemptForNote = false
}
}
is AudioProcessor.ProcessedResult.Silence,
is AudioProcessor.ProcessedResult.NoNote -> {
correctHoldJob?.cancel()
correctHoldJob = null
_uiState.value = state.copy(
detectedNoteName = "",
detectedCents = 0f,
matchState = NoteMatchState.WAITING,
)
}
}
}
}
}
private fun advanceToNext() {
correctHoldJob = null
val state = _uiState.value
val now = currentTimeMillis()
// Collect result for the completed note
state.targetNote?.let { target ->
collectedResults.add(
NoteResult(
noteName = target.fullName,
midiNumber = target.midiNumber,
centsDeviation = lastCentsForNote,
reactionTimeMs = now - noteStartTime,
firstAttemptSuccess = firstAttemptForNote,
)
)
}
val nextIndex = state.currentIndex + 1
if (nextIndex >= state.sequence.size) {
val session = buildSession(now, completed = true)
_uiState.value = state.copy(
isCompleted = true,
progress = 1f,
matchState = NoteMatchState.WAITING,
lastSession = session,
)
} else {
noteStartTime = now
firstAttemptForNote = true
lastCentsForNote = 0f
_uiState.value = state.copy(
currentIndex = nextIndex,
targetNote = state.sequence.noteAt(nextIndex),
matchState = NoteMatchState.WAITING,
detectedNoteName = "",
progress = nextIndex.toFloat() / state.sequence.size,
)
}
}
private fun buildSession(endTime: Long, completed: Boolean): PracticeSession {
val state = _uiState.value
return PracticeSession(
id = sessionStartTime.toString(),
sequenceName = state.sequence.name,
instrumentName = currentInstrument.displayName,
dateMillis = sessionStartTime,
totalTimeMs = endTime - sessionStartTime,
noteResults = collectedResults.toList(),
noteMidiNumbers = state.sequence.notes.map { it.midiNumber },
completed = completed,
)
}
fun saveSession() {
_uiState.value.lastSession?.let { session ->
SessionRepository.save(session)
DailyGoalManager.recordPractice(session.totalTimeMs)
}
}
fun savePartialSession() {
val state = _uiState.value
if (state.isCompleted || sessionStartTime == 0L || state.sequence.size == 0) return
if (collectedResults.isEmpty()) return
val session = buildSession(currentTimeMillis(), completed = false)
SessionRepository.save(session)
DailyGoalManager.recordPractice(session.totalTimeMs)
}
fun stopListening() {
isListening = false
audioProcessor.stop()
correctHoldJob?.cancel()
correctHoldJob = null
}
fun reset() {
stopListening()
val seq = _uiState.value.sequence
collectedResults.clear()
sessionStartTime = currentTimeMillis()
noteStartTime = sessionStartTime
firstAttemptForNote = true
lastCentsForNote = 0f
_uiState.value = PracticeUiState(
sequence = seq,
targetNote = seq.noteAt(0),
)
startListening()
}
override fun onCleared() {
super.onCleared()
stopListening()
}
}
@@ -0,0 +1,206 @@
package com.notemusicali.ui.reference
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.audio.NoteConverter
import com.notemusicali.audio.ToneGenerator
import com.notemusicali.audio.createToneGenerator
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import com.notemusicali.music.ItalianNotation
import com.notemusicali.ui.components.BackTopBar
private val NOTE_NAMES = (0..11).map { ItalianNotation.fromNoteIndex(it) }
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReferenceNoteScreen(onBack: () -> Unit) {
val toneGenerator = remember { createToneGenerator() }
var selectedNoteIndex by remember { mutableIntStateOf(9) } // A (La)
var selectedOctave by remember { mutableIntStateOf(4) }
var isPlaying by remember { mutableStateOf(false) }
val midiNumber = (selectedOctave + 1) * 12 + selectedNoteIndex
val frequency = NoteConverter.midiToFrequency(midiNumber)
val noteName = NOTE_NAMES[selectedNoteIndex].display
DisposableEffect(Unit) {
onDispose { toneGenerator.release() }
}
fun playNote(freq: Float = NoteConverter.midiToFrequency((selectedOctave + 1) * 12 + selectedNoteIndex)) {
toneGenerator.playTone(freq, 3000L)
isPlaying = true
}
fun stopNote() {
toneGenerator.stop()
isPlaying = false
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.widthIn(max = 600.dp)) {
BackTopBar(title = stringResource(Res.string.reference_title), onBack = {
stopNote()
onBack()
})
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(24.dp))
// Quick access: A4 440Hz
Button(
onClick = {
selectedNoteIndex = 9
selectedOctave = 4
toneGenerator.playTone(440f, 3000L)
isPlaying = true
},
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFF9800).copy(alpha = 0.2f),
),
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(Res.string.la_440), fontSize = 18.sp, color = Color(0xFFFF9800))
}
Spacer(modifier = Modifier.height(32.dp))
// Note display
Text(
text = noteName,
fontSize = 72.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
Text(
text = stringResource(Res.string.octave_freq, selectedOctave, ((frequency * 10).toInt() / 10f).toString()),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(24.dp))
// Play/Stop button
IconButton(
onClick = { if (isPlaying) stopNote() else playNote() },
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(
if (isPlaying) Color(0xFFF44336).copy(alpha = 0.3f)
else MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
),
) {
Icon(
imageVector = if (isPlaying) Icons.Filled.Stop else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Stop" else "Play",
tint = Color.White,
modifier = Modifier.size(40.dp),
)
}
Spacer(modifier = Modifier.height(32.dp))
// Note selector
Text(stringResource(Res.string.select_note), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
NOTE_NAMES.forEachIndexed { idx, name ->
FilterChip(
selected = selectedNoteIndex == idx,
onClick = {
selectedNoteIndex = idx
if (isPlaying) playNote()
},
label = { Text(name.display, fontSize = 13.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Octave selector
Text(stringResource(Res.string.octave), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
(2..7).forEach { oct ->
FilterChip(
selected = selectedOctave == oct,
onClick = {
selectedOctave = oct
if (isPlaying) playNote()
},
label = { Text("$oct") },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@@ -0,0 +1,247 @@
package com.notemusicali.ui.scales
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.music.ArpeggioType
import com.notemusicali.music.ItalianNotation
import com.notemusicali.music.NoteSequence
import com.notemusicali.music.ScaleDirection
import com.notemusicali.music.ScaleGenerator
import com.notemusicali.music.ScaleType
import com.notemusicali.ui.components.BackTopBar
import com.notemusicali.ui.components.StaffFullView
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
private val NOTE_NAMES = (0..11).map { ItalianNotation.fromNoteIndex(it) }
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ScaleGeneratorScreen(
onBack: () -> Unit,
onPractice: (NoteSequence) -> Unit,
) {
var selectedTab by remember { mutableIntStateOf(0) } // 0=Scale, 1=Arpeggio
var selectedNoteIndex by remember { mutableIntStateOf(0) } // C
var selectedOctave by remember { mutableIntStateOf(4) }
var selectedScaleType by remember { mutableStateOf(ScaleType.MAJOR) }
var selectedArpeggioType by remember { mutableStateOf(ArpeggioType.MAJOR) }
var selectedDirection by remember { mutableStateOf(ScaleDirection.ASCENDING) }
val preview = remember(selectedTab, selectedNoteIndex, selectedOctave, selectedScaleType, selectedArpeggioType, selectedDirection) {
if (selectedTab == 0) {
ScaleGenerator.generateScale(selectedNoteIndex, selectedOctave, selectedScaleType, selectedDirection)
} else {
ScaleGenerator.generateArpeggio(selectedNoteIndex, selectedOctave, selectedArpeggioType, selectedDirection)
}
}
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.widthIn(max = 600.dp)) {
BackTopBar(title = stringResource(Res.string.scales_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Tab: Scale / Arpeggio
TabRow(
selectedTabIndex = selectedTab,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.primary,
) {
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
Text(stringResource(Res.string.tab_scales), modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) {
Text(stringResource(Res.string.tab_arpeggios), modifier = Modifier.padding(12.dp))
}
}
Spacer(modifier = Modifier.height(16.dp))
// Root note selector
Text(stringResource(Res.string.root_note), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
NOTE_NAMES.forEachIndexed { idx, name ->
FilterChip(
selected = selectedNoteIndex == idx,
onClick = { selectedNoteIndex = idx },
label = { Text(name.display, fontSize = 13.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Octave selector
Text(stringResource(Res.string.octave), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
(2..6).forEach { oct ->
FilterChip(
selected = selectedOctave == oct,
onClick = { selectedOctave = oct },
label = { Text("$oct") },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Scale/Arpeggio type
if (selectedTab == 0) {
Text(stringResource(Res.string.scale_type), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
ScaleType.entries.forEach { type ->
FilterChip(
selected = selectedScaleType == type,
onClick = { selectedScaleType = type },
label = { Text(type.displayName, fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
} else {
Text(stringResource(Res.string.arpeggio_type), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
ArpeggioType.entries.forEach { type ->
FilterChip(
selected = selectedArpeggioType == type,
onClick = { selectedArpeggioType = type },
label = { Text(type.displayName, fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Direction
Text(stringResource(Res.string.direction), fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f))
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ScaleDirection.entries.forEach { dir ->
FilterChip(
selected = selectedDirection == dir,
onClick = { selectedDirection = dir },
label = { Text(dir.displayName, fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
selectedLabelColor = Color.White,
),
)
}
}
Spacer(modifier = Modifier.height(20.dp))
// Preview
if (preview.notes.isNotEmpty()) {
Text(preview.name, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White)
.padding(8.dp)
) {
StaffFullView(
notes = preview.notes,
currentIndex = -1,
lightTheme = true,
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { onPractice(preview) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(Res.string.start_practice), fontSize = 18.sp)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@@ -0,0 +1,6 @@
package com.notemusicali.ui.scan
enum class AiProvider(val displayName: String) {
CLAUDE("Claude (Anthropic)"),
OPENAI("GPT-4o (OpenAI)"),
}
@@ -0,0 +1,15 @@
package com.notemusicali.ui.scan
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.notemusicali.scan.PlatformImage
/**
* Platform-specific camera capture composable.
* On platforms without camera support, shows a placeholder.
*/
@Composable
expect fun CameraCapture(
onImageCaptured: (PlatformImage) -> Unit,
modifier: Modifier,
)
@@ -0,0 +1,427 @@
package com.notemusicali.ui.scan
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.notemusicali.audio.InstrumentPreset
import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteSequence
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun ScanScreen(
onBack: () -> Unit,
onSequenceCaptured: (NoteSequence, InstrumentPreset) -> Unit,
viewModel: ScanViewModel,
) {
val state by viewModel.state.collectAsState()
val showApiKeyDialog by viewModel.showApiKeyDialog.collectAsState()
val selectedProvider by viewModel.selectedProvider.collectAsState()
if (showApiKeyDialog) {
ApiKeyDialog(
provider = selectedProvider,
onProviderChange = { viewModel.selectProvider(it) },
onConfirm = { key -> viewModel.saveApiKey(key) },
onDismiss = {
viewModel.dismissApiKeyDialog()
if (!viewModel.hasApiKey()) onBack()
},
)
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Column(modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight()) {
BackTopBar(
title = stringResource(Res.string.scan_title),
onBack = onBack,
contentColor = when (state) {
is ScanViewModel.ScanState.Camera -> Color.White
else -> MaterialTheme.colorScheme.primary
},
containerColor = Color.Transparent,
)
when (val currentState = state) {
is ScanViewModel.ScanState.Camera -> CameraView(
onCapture = { image -> viewModel.captureAndAnalyze(image) },
onProviderClick = { viewModel.requestApiKey() },
providerName = selectedProvider.displayName,
)
is ScanViewModel.ScanState.Processing -> ProcessingView()
is ScanViewModel.ScanState.Review -> ReviewView(
notes = currentState.notes,
onRemoveNote = { viewModel.removeNote(it) },
onRetry = { viewModel.retryCamera() },
onStartPractice = { instrument ->
onSequenceCaptured(
NoteSequence(
name = "Spartito Scansionato",
notes = currentState.notes,
),
instrument,
)
},
)
is ScanViewModel.ScanState.Error -> ErrorView(
message = currentState.message,
onRetry = { viewModel.retryCamera() },
onChangeKey = { viewModel.requestApiKey() },
)
}
}
}
}
@Composable
private fun CameraView(
onCapture: (com.notemusicali.scan.PlatformImage) -> Unit,
onProviderClick: () -> Unit,
providerName: String,
) {
Box(modifier = Modifier.fillMaxSize()) {
CameraCapture(
onImageCaptured = onCapture,
modifier = Modifier.fillMaxSize(),
)
// Provider badge
Text(
text = providerName,
fontSize = 12.sp,
color = Color.White,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
.background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
.clickable { onProviderClick() }
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
@Composable
private fun ProcessingView() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(64.dp),
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(Res.string.analyzing),
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = stringResource(Res.string.analyzing_desc),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f),
)
}
}
}
@Composable
private fun ReviewView(
notes: List<MusicalNote>,
onRemoveNote: (Int) -> Unit,
onRetry: () -> Unit,
onStartPractice: (InstrumentPreset) -> Unit,
) {
var selectedInstrument by remember { mutableStateOf(InstrumentPreset.VIOLINO) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(Res.string.notes_detected, notes.size),
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
if (notes.isNotEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.border(
1.dp,
MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
RoundedCornerShape(8.dp),
)
.padding(12.dp),
) {
itemsIndexed(notes) { index, note ->
val chipShape = RoundedCornerShape(8.dp)
val chipGradient = Brush.linearGradient(
listOf(Color(0xFF1565C0), Color(0xFF7B1FA2)),
)
Text(
text = note.fullName,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White,
modifier = Modifier
.clip(chipShape)
.background(chipGradient)
.border(1.dp, Color.White.copy(alpha = 0.15f), chipShape)
.clickable { onRemoveNote(index) }
.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
Text(
text = stringResource(Res.string.tap_to_remove),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f),
modifier = Modifier.padding(top = 4.dp),
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(Res.string.instrument),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
InstrumentPreset.entries.forEach { preset ->
FilterChip(
selected = selectedInstrument == preset,
onClick = { selectedInstrument = preset },
label = { Text(preset.displayName, fontSize = 13.sp) },
)
}
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = { onStartPractice(selectedInstrument) },
enabled = notes.size >= 2,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(Res.string.start_practice_n, notes.size), fontSize = 18.sp)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onRetry,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(Res.string.take_again))
}
}
}
@Composable
private fun ErrorView(
message: String,
onRetry: () -> Unit,
onChangeKey: () -> Unit,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp),
) {
Text(
text = stringResource(Res.string.error),
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = message,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(32.dp))
Button(onClick = onRetry, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(Res.string.retry))
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = onChangeKey, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(Res.string.change_api_key))
}
}
}
}
@Composable
private fun ApiKeyDialog(
provider: AiProvider,
onProviderChange: (AiProvider) -> Unit,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var apiKey by remember { mutableStateOf("") }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(Res.string.api_key),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AiProvider.entries.forEach { p ->
FilterChip(
selected = provider == p,
onClick = { onProviderChange(p) },
label = { Text(p.displayName, fontSize = 12.sp) },
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = when (provider) {
AiProvider.CLAUDE -> stringResource(Res.string.enter_anthropic_key)
AiProvider.OPENAI -> stringResource(Res.string.enter_openai_key)
},
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = apiKey,
onValueChange = { apiKey = it },
label = { Text(stringResource(Res.string.api_key)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = when (provider) {
AiProvider.CLAUDE -> stringResource(Res.string.get_anthropic_key)
AiProvider.OPENAI -> stringResource(Res.string.get_openai_key)
},
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f),
) {
Text(stringResource(Res.string.cancel))
}
Button(
onClick = { onConfirm(apiKey) },
enabled = apiKey.isNotBlank(),
modifier = Modifier.weight(1f),
) {
Text(stringResource(Res.string.save))
}
}
}
}
}
}
@@ -0,0 +1,122 @@
package com.notemusicali.ui.scan
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.notemusicali.music.MusicalNote
import com.notemusicali.scan.AppSettings
import com.notemusicali.scan.ClaudeOmrProcessor
import com.notemusicali.scan.OpenAiOmrProcessor
import com.notemusicali.scan.PlatformImage
import com.notemusicali.scan.toBase64Jpeg
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class ScanViewModel : ViewModel() {
sealed class ScanState {
data object Camera : ScanState()
data object Processing : ScanState()
data class Review(val notes: List<MusicalNote>) : ScanState()
data class Error(val message: String) : ScanState()
}
private val _state = MutableStateFlow<ScanState>(ScanState.Camera)
val state: StateFlow<ScanState> = _state.asStateFlow()
private val _showApiKeyDialog = MutableStateFlow(!hasApiKey())
val showApiKeyDialog: StateFlow<Boolean> = _showApiKeyDialog.asStateFlow()
private val _selectedProvider = MutableStateFlow(loadProvider())
val selectedProvider: StateFlow<AiProvider> = _selectedProvider.asStateFlow()
fun hasApiKey(): Boolean {
val provider = loadProvider()
return getApiKey(provider) != null
}
fun getApiKey(provider: AiProvider = _selectedProvider.value): String? {
val key = when (provider) {
AiProvider.CLAUDE -> "anthropic_api_key"
AiProvider.OPENAI -> "openai_api_key"
}
return AppSettings.getString(key)?.takeIf { it.isNotBlank() }
}
fun saveApiKey(key: String, provider: AiProvider = _selectedProvider.value) {
val prefKey = when (provider) {
AiProvider.CLAUDE -> "anthropic_api_key"
AiProvider.OPENAI -> "openai_api_key"
}
AppSettings.putString(prefKey, key.trim())
_showApiKeyDialog.value = false
}
fun selectProvider(provider: AiProvider) {
_selectedProvider.value = provider
AppSettings.putString("selected_provider", provider.name)
if (getApiKey(provider) == null) {
_showApiKeyDialog.value = true
}
}
private fun loadProvider(): AiProvider {
val name = AppSettings.getString("selected_provider")
return try {
name?.let { AiProvider.valueOf(it) } ?: AiProvider.CLAUDE
} catch (_: Exception) {
AiProvider.CLAUDE
}
}
fun requestApiKey() {
_showApiKeyDialog.value = true
}
fun dismissApiKeyDialog() {
_showApiKeyDialog.value = false
}
fun captureAndAnalyze(image: PlatformImage) {
val provider = _selectedProvider.value
val apiKey = getApiKey(provider)
if (apiKey == null) {
_showApiKeyDialog.value = true
return
}
_state.value = ScanState.Processing
viewModelScope.launch {
val base64 = image.toBase64Jpeg()
val result = when (provider) {
AiProvider.CLAUDE -> ClaudeOmrProcessor.analyze(base64, apiKey)
AiProvider.OPENAI -> OpenAiOmrProcessor.analyze(base64, apiKey)
}
result.fold(
onSuccess = { notes ->
_state.value = ScanState.Review(notes)
},
onFailure = { error ->
_state.value = ScanState.Error(
error.message ?: "Errore sconosciuto durante l'analisi"
)
},
)
}
}
fun removeNote(index: Int) {
val current = _state.value
if (current is ScanState.Review) {
val updated = current.notes.toMutableList().apply { removeAt(index) }
_state.value = ScanState.Review(updated)
}
}
fun retryCamera() {
_state.value = ScanState.Camera
}
}
@@ -0,0 +1,117 @@
package com.notemusicali.ui.stats
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.sp
@Composable
fun LineChart(
data: List<Float>,
labels: List<String> = emptyList(),
lineColor: Color = Color(0xFF90CAF9),
fillColor: Color = lineColor.copy(alpha = 0.15f),
modifier: Modifier = Modifier,
yAxisLabel: String = "",
) {
val textMeasurer = rememberTextMeasurer()
Canvas(modifier = modifier) {
if (data.isEmpty()) return@Canvas
val paddingLeft = 40f
val paddingBottom = 30f
val paddingTop = 16f
val paddingRight = 16f
val chartWidth = size.width - paddingLeft - paddingRight
val chartHeight = size.height - paddingTop - paddingBottom
val maxVal = data.max().coerceAtLeast(1f)
val minVal = 0f
// Draw grid lines
val gridColor = Color.White.copy(alpha = 0.1f)
for (i in 0..4) {
val y = paddingTop + chartHeight * (1f - i / 4f)
drawLine(gridColor, Offset(paddingLeft, y), Offset(size.width - paddingRight, y))
val labelVal = (minVal + (maxVal - minVal) * i / 4f).toInt().toString()
val result = textMeasurer.measure(
text = labelVal,
style = TextStyle(fontSize = 9.sp, color = Color.White.copy(alpha = 0.4f)),
)
drawText(result, topLeft = Offset(paddingLeft - result.size.width - 4f, y - result.size.height / 2f))
}
// Draw data points and line
if (data.size == 1) {
val x = paddingLeft + chartWidth / 2
val y = paddingTop + chartHeight * (1f - (data[0] - minVal) / (maxVal - minVal))
drawCircle(lineColor, radius = 4f, center = Offset(x, y))
return@Canvas
}
val stepX = chartWidth / (data.size - 1).coerceAtLeast(1)
val points = data.mapIndexed { index, value ->
val x = paddingLeft + stepX * index
val normalized = ((value - minVal) / (maxVal - minVal)).coerceIn(0f, 1f)
val y = paddingTop + chartHeight * (1f - normalized)
Offset(x, y)
}
// Fill area
val fillPath = Path().apply {
moveTo(points.first().x, paddingTop + chartHeight)
points.forEach { lineTo(it.x, it.y) }
lineTo(points.last().x, paddingTop + chartHeight)
close()
}
drawPath(fillPath, fillColor, style = Fill)
// Line
val linePath = Path().apply {
moveTo(points.first().x, points.first().y)
for (i in 1 until points.size) {
lineTo(points[i].x, points[i].y)
}
}
drawPath(linePath, lineColor, style = Stroke(width = 2f))
// Points
points.forEach { point ->
drawCircle(lineColor, radius = 3f, center = point)
}
// X-axis labels
if (labels.isNotEmpty()) {
val labelStep = maxOf(1, labels.size / 6)
labels.forEachIndexed { index, label ->
if (index % labelStep == 0 || index == labels.lastIndex) {
val x = paddingLeft + stepX * index
val result = textMeasurer.measure(
text = label,
style = TextStyle(fontSize = 8.sp, color = Color.White.copy(alpha = 0.4f)),
)
drawText(result, topLeft = Offset(x - result.size.width / 2f, paddingTop + chartHeight + 4f))
}
}
}
// Y-axis label
if (yAxisLabel.isNotEmpty()) {
val result = textMeasurer.measure(
text = yAxisLabel,
style = TextStyle(fontSize = 9.sp, color = Color.White.copy(alpha = 0.4f)),
)
drawText(result, topLeft = Offset(0f, paddingTop - result.size.height - 2f))
}
}
}
@@ -0,0 +1,179 @@
package com.notemusicali.ui.stats
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.history.SessionRepository
import com.notemusicali.stats.StatsCalculator
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@Composable
fun StatsScreen(onBack: () -> Unit) {
val sessions = remember { SessionRepository.load() }
val overall = remember(sessions) { StatsCalculator.calculateOverall(sessions) }
val daily = remember(sessions) { StatsCalculator.calculateDaily(sessions) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.widthIn(max = 600.dp)) {
BackTopBar(title = stringResource(Res.string.stats_title), onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) {
// Summary cards
Text(stringResource(Res.string.summary), fontSize = 20.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
SummaryCard(
label = stringResource(Res.string.sessions),
value = "${overall.totalSessions}",
modifier = Modifier.weight(1f),
)
SummaryCard(
label = stringResource(Res.string.sessions_completed),
value = "${overall.completedSessions}",
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
val totalMin = overall.totalPracticeTimeMs / 60_000
SummaryCard(
label = stringResource(Res.string.practice_time),
value = if (totalMin < 60) "${totalMin}m" else "${totalMin / 60}h ${totalMin % 60}m",
modifier = Modifier.weight(1f),
)
SummaryCard(
label = stringResource(Res.string.best_accuracy),
value = "${overall.bestAccuracyCents.toInt()} cents",
modifier = Modifier.weight(1f),
)
}
if (daily.isNotEmpty()) {
Spacer(modifier = Modifier.height(28.dp))
// Cents accuracy chart
ChartSection(
title = stringResource(Res.string.accuracy_cents),
data = daily.map { it.averageCents },
labels = daily.map { it.dateLabel },
lineColor = Color(0xFF4CAF50),
yAxisLabel = stringResource(Res.string.cents),
)
Spacer(modifier = Modifier.height(24.dp))
// First attempt rate chart
ChartSection(
title = stringResource(Res.string.first_attempt_pct),
data = daily.map { it.firstAttemptRate * 100f },
labels = daily.map { it.dateLabel },
lineColor = Color(0xFF2196F3),
yAxisLabel = stringResource(Res.string.pct),
)
Spacer(modifier = Modifier.height(24.dp))
// Reaction time chart
ChartSection(
title = stringResource(Res.string.reaction_time_ms),
data = daily.map { it.averageReactionMs.toFloat() },
labels = daily.map { it.dateLabel },
lineColor = Color(0xFFFF9800),
yAxisLabel = stringResource(Res.string.ms),
)
} else {
Spacer(modifier = Modifier.height(48.dp))
Text(
stringResource(Res.string.no_stats_yet),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.5f),
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@Composable
private fun SummaryCard(label: String, value: String, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.08f))
.padding(16.dp),
) {
Column {
Text(label, fontSize = 12.sp, color = Color.White.copy(alpha = 0.5f))
Text(value, fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
@Composable
private fun ChartSection(
title: String,
data: List<Float>,
labels: List<String>,
lineColor: Color,
yAxisLabel: String,
) {
Text(title, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.05f))
.padding(12.dp),
) {
LineChart(
data = data,
labels = labels,
lineColor = lineColor,
modifier = Modifier.fillMaxSize(),
yAxisLabel = yAxisLabel,
)
}
}
@@ -0,0 +1,107 @@
package com.notemusicali.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
// Remotion video-inspired palette
object AppColors {
val bgDark = Color(0xFF0A0118)
val bgPurple = Color(0xFF1A0533)
val bgIndigo = Color(0xFF2D1B69)
val accent = Color(0xFFFFD700) // Gold
val accentWarm = Color(0xFFFFA500) // Orange-gold
val accentRose = Color(0xFFFF6B9D) // Rose
val text = Color.White
val textMuted = Color.White.copy(alpha = 0.6f)
val cardBg = Color.White.copy(alpha = 0.06f)
val cardBorder = Color.White.copy(alpha = 0.12f)
val noteGlow = Color(0x66FFD700) // Gold glow
}
private val DarkColorScheme = darkColorScheme(
primary = AppColors.accent,
secondary = AppColors.accentRose,
tertiary = Color(0xFF4ECDC4),
background = AppColors.bgDark,
surface = AppColors.bgPurple,
onPrimary = Color.Black,
onSecondary = Color.Black,
onTertiary = Color.Black,
onBackground = Color.White,
onSurface = Color.White,
surfaceVariant = AppColors.bgIndigo,
)
object CardGradients {
// Home cards — glass-like with subtle colored tints on dark purple base
val tuner = Brush.linearGradient(listOf(Color(0xFF1A1040), Color(0xFF2D1B69)))
val practice = Brush.linearGradient(listOf(Color(0xFF0D1F2D), Color(0xFF1A0533)))
val exercises = Brush.linearGradient(listOf(Color(0xFF2A1020), Color(0xFF1A0533)))
val history = Brush.linearGradient(listOf(Color(0xFF1A0533), Color(0xFF0A0118)))
val scores = Brush.linearGradient(listOf(Color(0xFF0D1A2D), Color(0xFF1A0533)))
val scoreFile = Brush.linearGradient(listOf(Color(0xFF151025), Color(0xFF0A0118)))
val metronome = Brush.linearGradient(listOf(Color(0xFF1E1035), Color(0xFF0A0118)))
val reference = Brush.linearGradient(listOf(Color(0xFF0D1F33), Color(0xFF1A0533)))
val scales = Brush.linearGradient(listOf(Color(0xFF101E15), Color(0xFF1A0533)))
val earTraining = Brush.linearGradient(listOf(Color(0xFF2D1050), Color(0xFF1A0533)))
val challenge = Brush.linearGradient(listOf(Color(0xFF2A0D1A), Color(0xFF1A0533)))
val stats = Brush.linearGradient(listOf(Color(0xFF0D1533), Color(0xFF1A0533)))
val goals = Brush.linearGradient(listOf(Color(0xFF2A1A05), Color(0xFF1A0533)))
val leaderboard = Brush.linearGradient(listOf(Color(0xFF151040), Color(0xFF1A0533)))
val guide = Brush.linearGradient(listOf(Color(0xFF151525), Color(0xFF0A0118)))
// Practice setup
val setup = Brush.linearGradient(listOf(Color(0xFF0D1F2D), Color(0xFF1A0533)))
// Exercise levels — subtle colored tints
val level1 = Brush.linearGradient(listOf(Color(0xFF0D2015), Color(0xFF1A0533)))
val level2 = Brush.linearGradient(listOf(Color(0xFF0D1533), Color(0xFF1A0533)))
val level3 = Brush.linearGradient(listOf(Color(0xFF2A1A05), Color(0xFF1A0533)))
val level4 = Brush.linearGradient(listOf(Color(0xFF2A0D15), Color(0xFF1A0533)))
fun forLevel(level: Int): Brush = when (level) {
1 -> level1
2 -> level2
3 -> level3
else -> level4
}
}
// Accent colors for each card category (used for top accent lines and icon tints)
object CardAccents {
val tuner = Color(0xFF4ECDC4)
val metronome = Color(0xFFFFD700)
val reference = Color(0xFF90CAF9)
val practice = Color(0xFF4CAF50)
val exercises = Color(0xFFFF6B9D)
val scales = Color(0xFF66BB6A)
val scores = Color(0xFF4DD0E1)
val earTraining = Color(0xFFCE93D8)
val challenge = Color(0xFFEF5350)
val leaderboard = Color(0xFF7986CB)
val stats = Color(0xFF42A5F5)
val goals = Color(0xFFFFA726)
val history = Color(0xFFAB47BC)
val guide = Color(0xFF78909C)
}
object TunerColors {
val inTune = Color(0xFF4CAF50)
val outOfTune = Color(0xFFF44336)
val silence = Color(0xFF424242)
val inTuneBackground = Color(0xFF0D200D)
val outOfTuneBackground = Color(0xFF2A0D0D)
val silenceBackground = AppColors.bgDark
}
@Composable
fun NoteMusicaliTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkColorScheme,
content = content,
)
}
@@ -0,0 +1,283 @@
package com.notemusicali.ui.tuner
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.notemusicali.audio.InstrumentPreset
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import com.notemusicali.ui.components.KeepScreenOn
import com.notemusicali.ui.theme.TunerColors
@Composable
fun TunerScreen(
onBack: () -> Unit,
viewModel: TunerViewModel = viewModel { TunerViewModel() },
) {
val state by viewModel.uiState.collectAsState()
KeepScreenOn()
DisposableEffect(Unit) {
viewModel.startListening()
onDispose { viewModel.stopListening() }
}
val backgroundColor by animateColorAsState(
targetValue = when (state.tuningState) {
TunerViewModel.TuningState.IN_TUNE -> TunerColors.inTuneBackground
TunerViewModel.TuningState.OUT_OF_TUNE -> TunerColors.outOfTuneBackground
TunerViewModel.TuningState.SILENCE -> TunerColors.silenceBackground
},
animationSpec = tween(durationMillis = 300),
label = "bgColor",
)
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.background(
Brush.verticalGradient(
listOf(Color.Transparent, Color.Black.copy(alpha = 0.4f)),
)
),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight(),
) {
BackTopBar(title = stringResource(Res.string.tuner_title), onBack = onBack)
// Instrument selector
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
InstrumentPreset.entries.forEach { preset ->
FilterChip(
selected = state.instrument == preset,
onClick = { viewModel.selectInstrument(preset) },
label = { Text(preset.displayName, fontSize = 13.sp) },
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.White.copy(alpha = 0.1f),
labelColor = Color.White.copy(alpha = 0.7f),
selectedContainerColor = Color.White.copy(alpha = 0.25f),
selectedLabelColor = Color.White,
),
)
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
) {
// Note name with glow background
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
.background(Color.White.copy(alpha = 0.08f))
.padding(horizontal = 48.dp, vertical = 16.dp),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (state.noteName.isNotEmpty()) state.noteName else "---",
fontSize = 96.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
textAlign = TextAlign.Center,
)
if (state.noteName.isNotEmpty()) {
Text(
text = stringResource(Res.string.octave_n, state.octave.toString()),
fontSize = 24.sp,
color = Color.White.copy(alpha = 0.7f),
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// Cents gauge
CentsGauge(
cents = state.centsDeviation,
isActive = state.tuningState != TunerViewModel.TuningState.SILENCE,
modifier = Modifier
.fillMaxWidth()
.height(60.dp),
)
Spacer(modifier = Modifier.height(24.dp))
// Frequency and cents info
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (state.frequency > 0f) {
Text(
text = "${(state.frequency * 10).toInt() / 10.0} Hz",
fontSize = 20.sp,
color = Color.White.copy(alpha = 0.6f),
)
}
if (state.tuningState != TunerViewModel.TuningState.SILENCE && state.frequency > 0f) {
Text(
text = " \u00B7 ",
fontSize = 20.sp,
color = Color.White.copy(alpha = 0.3f),
)
val sign = if (state.centsDeviation >= 0) "+" else ""
Text(
text = "${sign}${state.centsDeviation.toInt()} cents",
fontSize = 20.sp,
color = Color.White.copy(alpha = 0.6f),
)
}
}
}
}
}
}
}
@Composable
private fun CentsGauge(
cents: Float,
isActive: Boolean,
modifier: Modifier = Modifier,
) {
val indicatorColor by animateColorAsState(
targetValue = when {
!isActive -> TunerColors.silence
kotlin.math.abs(cents) <= 15f -> TunerColors.inTune
else -> TunerColors.outOfTune
},
animationSpec = tween(durationMillis = 200),
label = "gaugeColor",
)
Canvas(modifier = modifier) {
val centerX = size.width / 2f
val centerY = size.height / 2f
val gaugeWidth = size.width * 0.8f
val gaugeLeft = centerX - gaugeWidth / 2f
// Background track
drawLine(
color = Color.White.copy(alpha = 0.15f),
start = Offset(gaugeLeft, centerY),
end = Offset(gaugeLeft + gaugeWidth, centerY),
strokeWidth = 8f,
cap = StrokeCap.Round,
)
// In-tune zone highlight
val zoneWidth = gaugeWidth * 15f / 50f
drawLine(
color = TunerColors.inTune.copy(alpha = 0.2f),
start = Offset(centerX - zoneWidth, centerY),
end = Offset(centerX + zoneWidth, centerY),
strokeWidth = 8f,
cap = StrokeCap.Round,
)
// Center tick mark
drawLine(
color = Color.White.copy(alpha = 0.6f),
start = Offset(centerX, centerY - 20f),
end = Offset(centerX, centerY + 20f),
strokeWidth = 2f,
)
// Zone boundary markers
drawLine(
color = Color.White.copy(alpha = 0.3f),
start = Offset(centerX - zoneWidth, centerY - 12f),
end = Offset(centerX - zoneWidth, centerY + 12f),
strokeWidth = 1f,
)
drawLine(
color = Color.White.copy(alpha = 0.3f),
start = Offset(centerX + zoneWidth, centerY - 12f),
end = Offset(centerX + zoneWidth, centerY + 12f),
strokeWidth = 1f,
)
// Indicator with glow
if (isActive) {
val clamped = cents.coerceIn(-50f, 50f)
val indicatorX = centerX + (clamped / 50f) * (gaugeWidth / 2f)
// Glow ring
drawCircle(
color = indicatorColor.copy(alpha = 0.3f),
radius = 20f,
center = Offset(indicatorX, centerY),
)
// Main indicator
drawCircle(
color = indicatorColor,
radius = 14f,
center = Offset(indicatorX, centerY),
)
// Inner highlight
drawCircle(
color = Color.White.copy(alpha = 0.3f),
radius = 6f,
center = Offset(indicatorX - 2f, centerY - 2f),
)
}
}
}
@@ -0,0 +1,104 @@
package com.notemusicali.ui.tuner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.notemusicali.audio.AudioProcessor
import com.notemusicali.audio.InstrumentPreset
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.math.abs
class TunerViewModel : ViewModel() {
private val audioProcessor = AudioProcessor()
companion object {
private const val HOLD_TIME_MS = 1500L
}
enum class TuningState {
SILENCE,
IN_TUNE,
OUT_OF_TUNE,
}
data class TunerUiState(
val noteName: String = "",
val octave: Int = 0,
val frequency: Float = 0f,
val centsDeviation: Float = 0f,
val confidence: Float = 0f,
val tuningState: TuningState = TuningState.SILENCE,
val instrument: InstrumentPreset = InstrumentPreset.VIOLINO,
)
private val _uiState = MutableStateFlow(TunerUiState())
val uiState: StateFlow<TunerUiState> = _uiState.asStateFlow()
private var isListening = false
private var silenceJob: Job? = null
fun selectInstrument(preset: InstrumentPreset) {
val wasListening = isListening
if (wasListening) stopListening()
audioProcessor.updatePreset(preset)
_uiState.value = TunerUiState(instrument = preset)
if (wasListening) startListening()
}
fun startListening() {
if (isListening) return
isListening = true
audioProcessor.start(viewModelScope)
viewModelScope.launch {
audioProcessor.result.collect { result ->
when (result) {
is AudioProcessor.ProcessedResult.Detected -> {
silenceJob?.cancel()
silenceJob = null
val noteResult = result.noteResult
val isInTune = abs(noteResult.centsDeviation) <= 15f
_uiState.value = _uiState.value.copy(
noteName = noteResult.note.displayName,
octave = noteResult.note.octave,
frequency = noteResult.frequency,
centsDeviation = noteResult.centsDeviation,
confidence = result.confidence,
tuningState = if (isInTune) TuningState.IN_TUNE else TuningState.OUT_OF_TUNE,
)
}
is AudioProcessor.ProcessedResult.Silence,
is AudioProcessor.ProcessedResult.NoNote -> {
if (silenceJob == null && _uiState.value.noteName.isNotEmpty()) {
silenceJob = viewModelScope.launch {
delay(HOLD_TIME_MS)
_uiState.value = _uiState.value.copy(
noteName = "",
tuningState = TuningState.SILENCE,
)
}
}
}
}
}
}
}
fun stopListening() {
isListening = false
silenceJob?.cancel()
silenceJob = null
audioProcessor.stop()
_uiState.value = TunerUiState()
}
override fun onCleared() {
super.onCleared()
stopListening()
}
}
@@ -0,0 +1,8 @@
package com.notemusicali.util
/**
* Platform-specific crash handler for logging uncaught exceptions.
*/
expect object CrashHandler {
fun install()
}
@@ -0,0 +1,8 @@
package com.notemusicali.util
expect fun currentTimeMillis(): Long
expect fun formatEpochDate(epochMillis: Long): String
/** Returns current date as "YYYYMMDD" string. */
expect fun currentDateString(): String