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:
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
|
||||
Reference in new issue
Block a user