commit 21ad969b100f0026ee073ee2538f702ad4dc9bb3 Author: luciano Date: Wed Jul 22 10:06:59 2026 +0200 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 diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..b44ee4e --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"db412f81-3e15-4e3f-9010-4a026dced776","pid":79175,"procStart":"Tue Jul 21 20:39:41 2026","acquiredAt":1784666538953} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18d29d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Gradle / Kotlin +.gradle/ +build/ +**/build/ +.kotlin/ +local.properties + +# IDE / OS +.idea/ +*.iml +.DS_Store + +# Xcode project is generated from iosApp/project.yml via xcodegen +iosApp/iosApp.xcodeproj/ +iosApp/build/ + +# Session/tooling artifacts +.superpowers/ +.claude/settings.local.json + +# Promo video: sources are versioned, deps and renders are not +video/node_modules/ +video/out/ + +# Score library (256MB of .mxl, not source code — the curated bundled set +# lives in shared/src/commonMain/composeResources/files/scores/) +scores/ + +# Binary release artifacts +*.apk +*.apk.idsig diff --git a/Apri con claude.command b/Apri con claude.command new file mode 100755 index 0000000..3b68f17 --- /dev/null +++ b/Apri con claude.command @@ -0,0 +1,3 @@ +#!/bin/zsh +cd "${0:A:h}" +exec claude diff --git a/GUIDA_UTENTE.md b/GUIDA_UTENTE.md new file mode 100644 index 0000000..5c0ecb0 --- /dev/null +++ b/GUIDA_UTENTE.md @@ -0,0 +1,162 @@ +# InTono — Guida Utente + +Intonazione e pratica per musicisti di ogni livello. + +--- + +## Strumenti + +### Controllo Intonazione +Rileva in tempo reale la nota che stai suonando tramite il microfono del dispositivo. Lo schermo mostra: +- **Nome della nota** rilevata (notazione italiana: Do, Re, Mi...) +- **Deviazione in cents** rispetto all'intonazione perfetta +- **Indicatore visivo** verde (intonato) o rosso (stonato) + +Ideale per strumenti a corda, a fiato o voce: verifica se stai suonando la nota giusta. + +### Metronomo +Un metronomo digitale con le seguenti funzioni: +- **BPM regolabile** da 30 a 240 tramite slider +- **Tempi disponibili**: 2/4, 3/4, 4/4, 6/8 +- **Indicatori visivi dei battiti** con accento sul primo battito +- **Tap Tempo**: tocca ripetutamente il pulsante per impostare il BPM dal tuo ritmo +- **Tuner integrato**: premi il pulsante microfono per attivare il rilevamento dell'intonazione mentre suoni a tempo. Mostra la nota rilevata, la deviazione in cents con indicatore visivo e la frequenza in Hz + +Premi il pulsante Play per avviare, Stop per fermare. + +### Nota di Riferimento +Genera una nota di riferimento per accordare o esercitarsi a orecchio: +- **Pulsante rapido LA 440 Hz** per l'accordatura standard +- **Griglia di 12 note** per selezionare qualsiasi nota cromatica +- **Ottava selezionabile** da 2 a 7 +- Mostra la frequenza esatta in Hz + +Premi Play per ascoltare la nota, Stop per fermare. + +--- + +## Pratica + +### Modalita Pratica +Il cuore dell'app. Carica una sequenza di note e suonale una per una: +1. Scegli come inserire le note: **manuale**, **scansione foto** o **file MusicXML** +2. Suona ogni nota mostrata sul pentagramma +3. L'app rileva la nota dal microfono e avanza automaticamente quando e' corretta +4. Al termine vedi le statistiche della sessione + +**Modalita di visualizzazione del pentagramma:** +- **Nota**: mostra solo la nota corrente +- **Contesto**: mostra le note vicine +- **Spartito**: mostra tutto il pentagramma +- **Chiaro**: sfondo bianco per leggibilita +- **Nascondi**: nasconde il nome della nota scritta, utile per esercitarsi a leggere dal pentagramma + +**Metronomo integrato:** sotto i selettori di visualizzazione c'e' una barra metronomo con play/stop, BPM e slider per suonare a tempo durante la pratica. + +Lo sfondo cambia colore: verde = nota corretta, rosso = nota sbagliata. + +### Esercizi +Esercizi pronti da suonare con difficolta progressiva: +- **Livello 1**: Note base (Do-Sol) +- **Livello 2**: Scale complete +- **Livello 3**: Scale con alterazioni e melodie +- **Livello 4**: Arpeggi e salti + +Tocca un esercizio per iniziare subito la pratica. + +### Scale e Arpeggi +Genera scale e arpeggi in qualsiasi tonalita: + +**Scale disponibili:** +- Maggiore, Minore Naturale, Armonica, Melodica +- Pentatonica Maggiore e Minore +- Cromatica + +**Arpeggi disponibili:** +- Maggiore, Minore, Diminuito, Aumentato +- Settima Dominante, Maggiore, Minore + +**Come usare:** +1. Seleziona la nota fondamentale (12 opzioni cromatiche) +2. Scegli l'ottava (2-6) +3. Scegli il tipo di scala o arpeggio +4. Scegli la direzione: ascendente, discendente o entrambe +5. Visualizza l'anteprima sul pentagramma +6. Tocca **Pratica** per esercitarti con il riconoscimento automatico + +### Spartiti +Sfoglia la libreria di file MusicXML e pratica direttamente dai brani importati. + +--- + +## Sfida + +### Ear Training +Allena il tuo orecchio musicale con due modalita: + +**Riconosci Nota**: ascolta una nota e indovina quale nota e'. +**Riconosci Intervallo**: ascolta due note e identifica l'intervallo (da unisono a ottava). + +**Livelli di difficolta:** +- **Facile**: poche note/intervalli semplici (unisono, terza, quinta, ottava) +- **Medio**: piu' note e intervalli comuni +- **Difficile**: tutte le 12 note e tutti i 13 intervalli + +Ogni sessione ha 10 domande. Premi il pulsante Play per ascoltare, poi tocca la risposta corretta tra le 4 opzioni. + +### Modalita Sfida +Metti alla prova la tua velocita e precisione: +1. Scegli la **sorgente**: esercizi, scale o note random +2. Scegli il **tempo limite**: 30 secondi, 1 minuto o 2 minuti +3. Suona il maggior numero di note corrette nel tempo! + +Ogni nota da suonare e' mostrata sia sul **pentagramma** che con il nome e l'ottava. + +**Sistema di punteggio:** +- 100 punti base per ogni nota corretta +- Bonus precisione: +50 punti se entro 5 cents, +25 se entro 15 cents +- Bonus velocita: +30 punti se suoni in meno di 2 secondi +- **Moltiplicatore combo**: suona note consecutive corrette per aumentare il punteggio fino a 2x + +### Classifica +Vedi i tuoi 20 migliori punteggi nelle sfide, ordinati dal piu' alto. Per ogni risultato sono indicati il punteggio, le note suonate, la combo massima e la data. + +--- + +## Progressi + +### Statistiche +Panoramica dei tuoi progressi nel tempo: + +**Riepilogo:** +- Numero totale di sessioni +- Sessioni completate +- Tempo totale di pratica +- Miglior precisione raggiunta + +**Grafici giornalieri:** +- **Precisione** (deviazione media in cents — piu' basso e' meglio) +- **Primo tentativo** (percentuale di note suonate al primo colpo) +- **Tempo di reazione** (velocita media nel suonare ogni nota) + +### Obiettivi Giornalieri +Mantieni la costanza nella pratica: +- **Imposta un obiettivo**: 5, 10, 15 o 30 minuti al giorno +- **Progresso circolare**: vedi quanto hai praticato oggi +- **Serie consecutiva**: il contatore si incrementa ogni giorno che raggiungi l'obiettivo +- **Calendario**: visualizza gli ultimi 30 giorni con i giorni di pratica colorati in verde + +Il tempo viene conteggiato da tutte le attivita: pratica, sfide e ear training. + +### Cronologia +Rivedi tutte le sessioni passate in un'unica lista cronologica: pratica, ear training e sfide. Ogni tipo ha un badge colorato per distinguerlo a colpo d'occhio. Tocca una sessione di pratica per rieseguirla. + +--- + +## Suggerimenti + +- **Ambiente silenzioso**: l'app funziona meglio in un ambiente con poco rumore di fondo +- **Distanza dal microfono**: tieni il dispositivo a 30-50 cm dallo strumento +- **Pratica quotidiana**: anche 10 minuti al giorno fanno la differenza — usa gli obiettivi giornalieri per restare motivato +- **Inizia dalle scale**: padroneggiare le scale e' la base per suonare qualsiasi brano +- **Ear training regolare**: anche 5 minuti di ear training al giorno migliorano notevolmente l'orecchio musicale diff --git a/InTono_Guida_Utente.pdf b/InTono_Guida_Utente.pdf new file mode 100644 index 0000000..33c009e Binary files /dev/null and b/InTono_Guida_Utente.pdf differ diff --git a/PRIVACY_POLICY.html b/PRIVACY_POLICY.html new file mode 100644 index 0000000..2e43712 --- /dev/null +++ b/PRIVACY_POLICY.html @@ -0,0 +1,67 @@ + + + + + + InTono — Privacy Policy + + + +

InTono — Privacy Policy

+

Ultimo aggiornamento: 27 febbraio 2026

+ +

1. Introduzione

+

InTono ("l'app") e' un'applicazione per l'accordatura, la pratica e l'allenamento musicale. Questa privacy policy descrive come l'app raccoglie, utilizza e protegge i tuoi dati.

+ +

2. Dati raccolti

+

2.1 Audio dal microfono

+

L'app accede al microfono del dispositivo per rilevare le note musicali suonate. L'audio viene elaborato localmente sul dispositivo in tempo reale e non viene registrato, salvato o trasmesso a server esterni.

+ +

2.2 Fotocamera e libreria foto

+

L'app puo' utilizzare la fotocamera o la libreria foto per scansionare immagini di spartiti musicali. Le immagini acquisite possono essere inviate a servizi di intelligenza artificiale (OpenAI o Anthropic) per il riconoscimento ottico della musica (OMR). Le immagini vengono trasmesse solo al momento della scansione e non vengono conservate sui server di terze parti oltre il tempo necessario all'elaborazione.

+ +

2.3 Dati di pratica

+

L'app memorizza localmente sul dispositivo le statistiche delle sessioni di pratica, i punteggi delle sfide e gli obiettivi giornalieri. Questi dati non vengono trasmessi a server esterni.

+ +

2.4 Chiavi API

+

Se utilizzi la funzione di scansione spartiti, l'app ti chiede di inserire una chiave API per i servizi di riconoscimento (OpenAI o Anthropic). La chiave viene memorizzata localmente sul dispositivo e trasmessa direttamente ai server del fornitore scelto solo durante le richieste di scansione.

+ +

3. Servizi di terze parti

+

L'app puo' comunicare con i seguenti servizi esterni esclusivamente per la funzione di scansione spartiti:

+ +

Nessun altro dato personale viene condiviso con questi servizi. Per le loro privacy policy, visita i rispettivi siti web.

+ +

4. Dati NON raccolti

+

L'app non raccoglie:

+ +

L'app non contiene pubblicita e non utilizza SDK di tracciamento o analytics.

+ +

5. Conservazione dei dati

+

Tutti i dati dell'app (statistiche, punteggi, impostazioni) sono memorizzati esclusivamente sul tuo dispositivo. Disinstallando l'app, tutti i dati vengono eliminati.

+ +

6. Sicurezza

+

Le comunicazioni con i servizi di terze parti avvengono esclusivamente tramite connessioni HTTPS crittografate. I dati locali sono protetti dai meccanismi di sicurezza del sistema operativo.

+ +

7. Diritti dell'utente

+

Puoi eliminare tutti i dati dell'app in qualsiasi momento disinstallandola dal dispositivo. Non essendoci dati conservati su server esterni (ad eccezione delle elaborazioni temporanee per la scansione spartiti), non e' necessaria alcuna richiesta di cancellazione.

+ +

8. Modifiche alla privacy policy

+

Eventuali modifiche a questa privacy policy saranno pubblicate in questa pagina con la data di aggiornamento. Ti consigliamo di consultarla periodicamente.

+ +

9. Contatti

+

Per domande sulla privacy, contattaci a: info@lzinformatica.it

+ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..350cc67 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# NoteMusicali + +App multipiattaforma per l'apprendimento musicale, costruita con **Kotlin Multiplatform** e **Compose Multiplatform**. + +## Funzionalita + +- **Controllo Intonazione** — rileva in tempo reale la nota suonata tramite microfono (algoritmo YIN) +- **Metronomo** — BPM regolabile, time signature, tap tempo e tuner integrato per suonare a tempo verificando l'intonazione +- **Nota di Riferimento** — genera qualsiasi nota cromatica con frequenza esatta per accordatura +- **Pratica guidata** — segui sequenze di note con feedback visivo e sonoro, metronomo integrato, modalita "Nascondi" per lettura dal pentagramma +- **Sfida** — suona il maggior numero di note nel tempo con punteggio, combo e pentagramma +- **Ear Training** — riconoscimento di note e intervalli con difficolta progressiva +- **Esercizi predefiniti** — scale, arpeggi e sequenze ordinate per difficolta +- **Inserimento manuale** — componi sequenze personalizzate con notazione italiana e durate musicali +- **Scansione spartiti** — riconosci note da foto di spartiti tramite AI (OpenAI / Gemini) +- **Pentagramma interattivo** — visualizzazione grafica delle note sul rigo musicale +- **Obiettivi giornalieri** — traccia il tempo di pratica da tutte le attivita con serie consecutiva e calendario +- **Cronologia unificata** — tutte le sessioni (pratica, ear training, sfide) in un'unica lista + +## Piattaforme supportate + +| Piattaforma | Stato | Note | +|-------------|-------|------| +| Android | Funzionante | Richiede API 26+ (Android 8.0) | +| iOS | Funzionante | Richiede iOS 16+, audio via AVAudioEngine | +| macOS | Funzionante | Desktop app via Compose Desktop | + +## Struttura progetto + +``` +NoteMusicali/ + app/ # Android app module + shared/ # Kotlin Multiplatform shared code + commonMain/ # Logica condivisa (UI, audio, pitch detection) + androidMain/ # Implementazioni Android (AudioRecord) + iosMain/ # Implementazioni iOS (AVAudioEngine) + desktopMain/ # Implementazioni Desktop (javax.sound) + iosApp/ # Xcode project (SwiftUI entry point) + desktopApp/ # Desktop app module (JVM) +``` + +## Prerequisiti + +- **JDK 17+** +- **Android Studio** (per Android) +- **Xcode 15+** (per iOS) +- **XcodeGen** (per generare il progetto iOS): `brew install xcodegen` + +## Build + +### Android + +```bash +./gradlew :app:assembleDebug +``` + +L'APK si trova in `app/build/outputs/apk/debug/`. + +### iOS + +```bash +# Genera il progetto Xcode +cd iosApp && xcodegen generate + +# Compila il framework Kotlin +./gradlew :shared:compileKotlinIosArm64 + +# Apri in Xcode, seleziona il device e Build & Run +open iosApp/iosApp.xcodeproj +``` + +Il pre-build script in Xcode esegue automaticamente `embedAndSignAppleFrameworkForXcode`. + +### macOS (Desktop) + +```bash +./gradlew :desktopApp:run # Esegui +./gradlew :desktopApp:packageDmg # Genera DMG +``` + +## Strumenti presets + +L'app supporta preset ottimizzati per diversi strumenti: + +| Strumento | Range | Buffer | +|-------------|-------------|--------| +| Violino | G3 - E7 | 2048 | +| Viola | C3 - A6 | 2048 | +| Violoncello | C2 - C6 | 4096 | +| Pianoforte | A0 - C8 | 4096 | +| Chitarra | E2 - E6 | 2048 | +| Voce | D2 - D6 | 2048 | + +## Stack tecnologico + +- **Kotlin 2.1** + **Compose Multiplatform 1.7** +- **Ktor** per le chiamate API (scansione spartiti) +- **multiplatform-settings** per preferenze persistenti +- **YIN algorithm** per pitch detection in tempo reale +- **AVAudioEngine** (iOS) / **AudioRecord** (Android) / **javax.sound** (Desktop) diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..f196894 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,70 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.notemusicali" + compileSdk = 35 + + defaultConfig { + applicationId = "com.notemusicali" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + + signingConfigs { + create("release") { + val keystorePath = System.getenv("INTONO_KEYSTORE_PATH") + if (keystorePath != null) { + storeFile = file(keystorePath) + storePassword = System.getenv("INTONO_KEYSTORE_PASSWORD") + keyAlias = System.getenv("INTONO_KEY_ALIAS") + keyPassword = System.getenv("INTONO_KEY_PASSWORD") + } + } + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + val keystorePath = System.getenv("INTONO_KEYSTORE_PATH") + if (keystorePath != null) { + signingConfig = signingConfigs.getByName("release") + } + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":shared")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.material3) + + debugImplementation(libs.androidx.ui.tooling) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f970252 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,64 @@ +# ProGuard rules for InTono (NoteMusicali) + +# --- Kotlin --- +-keepattributes *Annotation* +-keepattributes Signature +-keepattributes InnerClasses +-keepattributes EnclosingMethod +-keep class kotlin.Metadata { *; } +-dontwarn kotlin.** + +# --- Kotlin Serialization --- +-keepattributes RuntimeVisibleAnnotations +-keep class kotlinx.serialization.** { *; } +-keepclassmembers class * { + @kotlinx.serialization.Serializable ; +} +-keepclassmembers @kotlinx.serialization.Serializable class * { + *** Companion; + *** serializer(...); + kotlinx.serialization.KSerializer serializer(...); +} +-keepclasseswithmembers class * { + kotlinx.serialization.KSerializer serializer(...); +} + +# --- Ktor --- +-keep class io.ktor.** { *; } +-dontwarn io.ktor.** +-keep class io.ktor.client.engine.** { *; } +-keepclassmembers class io.ktor.** { volatile ; } + +# --- Coroutines --- +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +-keepclassmembers class kotlinx.coroutines.** { volatile ; } +-dontwarn kotlinx.coroutines.** + +# --- Compose --- +-keep class androidx.compose.** { *; } +-dontwarn androidx.compose.** + +# --- Multiplatform Settings --- +-keep class com.russhwolf.settings.** { *; } + +# --- App classes --- +-keep class com.notemusicali.** { *; } + +# --- CameraX --- +-keep class androidx.camera.** { *; } +-dontwarn androidx.camera.** + +# --- Standard Android --- +-keepclassmembers class * implements android.os.Parcelable { + public static final ** CREATOR; +} +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + !static !transient ; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0cbaaff --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/notemusicali/MainActivity.kt b/app/src/main/java/com/notemusicali/MainActivity.kt new file mode 100644 index 0000000..34ea15c --- /dev/null +++ b/app/src/main/java/com/notemusicali/MainActivity.kt @@ -0,0 +1,61 @@ +package com.notemusicali + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.Surface +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.Modifier +import androidx.core.content.ContextCompat +import com.notemusicali.navigation.AppNavigation +import com.notemusicali.scores.ScoreProviderContext +import com.notemusicali.scan.AppSettings +import com.notemusicali.ui.theme.NoteMusicaliTheme +import com.notemusicali.util.CrashHandler + +class MainActivity : ComponentActivity() { + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { _ -> } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + CrashHandler.install() + AppSettings.migrateIfNeeded() + enableEdgeToEdge() + ScoreProviderContext.init(this, getExternalFilesDir(null)) + + val permissionsToRequest = mutableListOf() + if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED + ) { + permissionsToRequest.add(Manifest.permission.RECORD_AUDIO) + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED + ) { + permissionsToRequest.add(Manifest.permission.CAMERA) + } + permissionsToRequest.forEach { requestPermissionLauncher.launch(it) } + + setContent { + NoteMusicaliTheme { + Surface( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding(), + color = MaterialTheme.colorScheme.background, + ) { + AppNavigation() + } + } + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ec051b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..cf8debe --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..c5387de Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..c5387de Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..06d1330 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..06d1330 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..f6d3833 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..f6d3833 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..416a54c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..416a54c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..19e2940 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..19e2940 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..c35d438 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + InTono + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..861dda9 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + + + + +
+ + +
+
♫
+

InTono

+
Accordatore, pratica e allenamento musicale
+
+ by LuZa — + djluza.com · + info@djluza.com +
+
+ + + + +
+
+ 🇮🇹 + Italiano — App Store Description +
+ + +
+
+ Testo Promozionale + max 170 caratteri +
+
Accorda, pratica e migliora con InTono: accordatore in tempo reale, metronomo, ear training, sfide e statistiche. Per ogni strumento e ogni livello.
+
+ + +
+
+ Descrizione + max 4000 caratteri +
+
InTono e' il tuo compagno di pratica musicale: tutto cio' che serve per accordare, esercitarti e migliorare, in un'unica app. Pensata per violino, viola, violoncello, chitarra, pianoforte e voce, a qualsiasi livello. + +ACCORDATORE IN TEMPO REALE +Rileva la nota che stai suonando tramite il microfono. Visualizza il nome della nota, la deviazione in cents e un indicatore colorato verde/rosso per capire al volo se sei intonato. + +METRONOMO +BPM regolabile da 30 a 240, tempi 2/4, 3/4, 4/4 e 6/8, indicatori visivi dei battiti e Tap Tempo per impostare il ritmo al volo. + +NOTA DI RIFERIMENTO +Genera qualsiasi nota cromatica dall'ottava 2 alla 7. Pulsante rapido LA 440 Hz per l'accordatura standard. + +PRATICA GUIDATA +Carica una sequenza di note — inserimento manuale, scansione foto dello spartito con IA, oppure importazione MusicXML — e suonale una per una. L'app rileva ogni nota dal microfono e avanza automaticamente quando e' corretta. Quattro viste del pentagramma: Nota, Contesto, Spartito e Chiaro. + +ESERCIZI PRONTI +4 livelli di difficolta' progressiva, dalle note base a scale con alterazioni, arpeggi e salti. + +SCALE E ARPEGGI +Genera scale e arpeggi in qualsiasi tonalita': +- Scale: Maggiore, Minore, Armonica, Melodica, Pentatonica, Cromatica +- Arpeggi: Maggiore, Minore, Diminuito, Aumentato, Settima +Scegli nota, ottava e direzione, poi esercitati con il riconoscimento automatico. + +SPARTITI +Sfoglia e pratica direttamente da file MusicXML importati nella libreria. + +EAR TRAINING +Allena l'orecchio musicale: riconosci note e intervalli con 3 livelli di difficolta'. Ogni sessione ha 10 domande con feedback immediato. + +MODALITA' SFIDA +Metti alla prova velocita' e precisione con sfide a tempo: 30 secondi, 1 o 2 minuti. Guadagna punti con bonus precisione, bonus velocita' e moltiplicatore combo. + +CLASSIFICA E STATISTICHE +Consulta i tuoi 20 migliori punteggi. Segui i tuoi progressi con grafici di precisione, velocita' e primo tentativo. + +OBIETTIVI GIORNALIERI +Imposta il tuo obiettivo: 5, 10, 15 o 30 minuti al giorno. Tieni traccia della serie consecutiva e visualizza gli ultimi 30 giorni sul calendario. + +CRONOLOGIA +Rivedi ogni sessione passata con le relative statistiche e rieseguila in qualsiasi momento. + +SCANSIONA SPARTITO CON IA +Scatta una foto al tuo spartito e lascia che l'intelligenza artificiale riconosca le note. Inizia subito a praticare. + +PRIVACY AL PRIMO POSTO +Tutto l'audio viene elaborato localmente sul dispositivo. Nessun tracciamento, nessuna pubblicita', nessun dato raccolto. + +Scarica InTono e inizia a migliorare oggi.
+
+ + +
+
+ Parole Chiave + max 100 caratteri +
+
accordatore,intonazione,metronomo,ear training,scale,arpeggi,violino,chitarra,voce,pratica,musicale
+
+
+ +
+ + + + +
+
+ 🇬🇧 + English — App Store Description +
+ + +
+
+ Promotional Text + max 170 characters +
+
Tune, practice and improve with InTono: real-time tuner, metronome, ear training, challenges and stats. For every instrument and every level.
+
+ + +
+
+ Description + max 4000 characters +
+
InTono is your music practice companion: everything you need to tune, practice and improve, all in one app. Designed for violin, viola, cello, guitar, piano and voice, at any level. + +REAL-TIME TUNER +Detects the note you are playing through the microphone. Shows the note name, cents deviation and a green/red colour indicator so you can instantly see if you are in tune. + +METRONOME +Adjustable BPM from 30 to 240, time signatures 2/4, 3/4, 4/4 and 6/8, visual beat indicators and Tap Tempo to set the pace on the fly. + +REFERENCE NOTE +Generate any chromatic note from octave 2 to 7. Quick A440 button for standard tuning. + +GUIDED PRACTICE +Load a note sequence — manual entry, photo scan of sheet music with AI, or MusicXML import — and play them one by one. The app detects each note from the microphone and advances automatically when correct. Four staff views: Note, Context, Score and Light. + +READY-MADE EXERCISES +4 progressive difficulty levels, from basic notes to scales with accidentals, arpeggios and leaps. + +SCALES AND ARPEGGIOS +Generate scales and arpeggios in any key: +- Scales: Major, Minor, Harmonic, Melodic, Pentatonic, Chromatic +- Arpeggios: Major, Minor, Diminished, Augmented, Seventh +Choose root note, octave and direction, then practise with automatic recognition. + +SHEET MUSIC +Browse and practise directly from MusicXML files imported into the library. + +EAR TRAINING +Train your musical ear: recognise notes and intervals across 3 difficulty levels. Each session has 10 questions with instant feedback. + +CHALLENGE MODE +Test your speed and accuracy with timed challenges: 30 seconds, 1 or 2 minutes. Earn points with accuracy bonus, speed bonus and combo multiplier. + +LEADERBOARD AND STATISTICS +View your top 20 scores. Track your progress with charts for accuracy, speed and first-attempt rate. + +DAILY GOALS +Set your target: 5, 10, 15 or 30 minutes per day. Keep track of your streak and view the last 30 days on the calendar. + +HISTORY +Review every past session with its statistics and replay it at any time. + +AI SHEET MUSIC SCANNER +Take a photo of your sheet music and let artificial intelligence recognise the notes. Start practising right away. + +PRIVACY FIRST +All audio is processed locally on your device. No tracking, no ads, no data collected. + +Download InTono and start improving today.
+
+ + +
+
+ Keywords + max 100 characters +
+
tuner,pitch,metronome,ear training,scales,arpeggios,violin,guitar,voice,practice,music,intonation
+
+
+ + + + +
+ + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..5330546 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.compose.multiplatform) apply false +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts new file mode 100644 index 0000000..e9dc64f --- /dev/null +++ b/desktopApp/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.kotlin.compose) +} + +kotlin { + jvm("desktop") { + compilations.all { + kotlinOptions { + jvmTarget = "17" + } + } + } + + sourceSets { + val desktopMain by getting { + dependencies { + implementation(project(":shared")) + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(compose.foundation) + implementation(compose.runtime) + } + } + } +} + +compose.desktop { + application { + mainClass = "com.notemusicali.MainKt" + + nativeDistributions { + targetFormats( + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg, + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi, + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb, + ) + packageName = "InTono" + packageVersion = "1.0.0" + + macOS { + infoPlist { + extraKeysRawXml = """ + NSMicrophoneUsageDescription + InTono ha bisogno del microfono per rilevare le note musicali suonate + """ + } + } + } + } +} diff --git a/desktopApp/src/desktopMain/kotlin/com/notemusicali/Main.kt b/desktopApp/src/desktopMain/kotlin/com/notemusicali/Main.kt new file mode 100644 index 0000000..0038130 --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/notemusicali/Main.kt @@ -0,0 +1,29 @@ +package com.notemusicali + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import com.notemusicali.navigation.AppNavigation +import com.notemusicali.ui.theme.NoteMusicaliTheme + +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + title = "InTono", + state = rememberWindowState(width = 1024.dp, height = 768.dp), + ) { + NoteMusicaliTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + AppNavigation() + } + } + } +} diff --git a/docs/superpowers/plans/2026-07-22-staff-rendering.md b/docs/superpowers/plans/2026-07-22-staff-rendering.md new file mode 100644 index 0000000..a8e72b5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-staff-rendering.md @@ -0,0 +1,577 @@ +# Staff Rendering Upgrade — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rendere il pentagramma con glifi raffinati, travature, stanghette di battuta con tempo, e spaziatura proporzionale alla durata, in tutte le viste Compose condivise. + +**Architecture:** La geometria (unità di durata, gruppi di travatura, posizioni battute, pesi/posizioni orizzontali, direzione gambi) vive in `StaffLayout` (pura, TDD). Le primitive di disegno (`Path` per teste/code, parallelogrammi per travature, stanghette, cifre tempo, chiave) vivono nel nuovo `StaffRenderer.kt` e sostituiscono `NoteRenderer.kt`. Le tre viste diventano orchestratori sottili. + +**Tech Stack:** Kotlin Multiplatform, Compose Multiplatform Canvas/DrawScope, kotlin-test. Spec: `docs/superpowers/specs/2026-07-22-staff-rendering-design.md`. + +**Nota:** il progetto NON è un repository git — al posto dei commit ogni task termina eseguendo la suite (`export JAVA_HOME=$(/usr/libexec/java_home) && ./gradlew :shared:desktopTest`). + +--- + +### Task 1: StaffLayout.quarterUnits + +**Files:** +- Modify: `shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffLayout.kt` +- Test: `shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffGeometryTest.kt` (create) + +- [ ] **Step 1: test fallente** + +```kotlin +package com.notemusicali.ui.components + +import com.notemusicali.music.MusicalNote +import com.notemusicali.music.NoteDuration +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class StaffGeometryTest { + @Test + fun `quarter units follow the binary duration ladder`() { + assertEquals(4.0, StaffLayout.quarterUnits(NoteDuration.WHOLE)) + assertEquals(2.0, StaffLayout.quarterUnits(NoteDuration.HALF)) + assertEquals(1.0, StaffLayout.quarterUnits(NoteDuration.QUARTER)) + assertEquals(0.5, StaffLayout.quarterUnits(NoteDuration.EIGHTH)) + assertEquals(0.25, StaffLayout.quarterUnits(NoteDuration.SIXTEENTH)) + assertEquals(0.125, StaffLayout.quarterUnits(NoteDuration.THIRTY_SECOND)) + } +} +``` + +- [ ] **Step 2: verifica RED** — `./gradlew :shared:compileTestKotlinDesktop` → errore `Unresolved reference 'quarterUnits'` +- [ ] **Step 3: implementazione minima** in `StaffLayout`: + +```kotlin + 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 + } +``` + +(aggiungere `import com.notemusicali.music.NoteDuration`) + +- [ ] **Step 4: verifica GREEN** — `./gradlew :shared:desktopTest --tests "com.notemusicali.ui.components.StaffGeometryTest"` → PASS + +### Task 2: StaffLayout.beamGroups + stemUpForGroup + +**Files:** come Task 1. + +- [ ] **Step 1: test fallenti** (aggiungere a `StaffGeometryTest`; helper `n(dur)` costruisce una nota DO4 con quella durata) + +```kotlin + private fun n(d: NoteDuration) = MusicalNote.fromNameAndOctave(0, 4, d) + + @Test + fun `consecutive eighths in the same beat are beamed together`() { + val notes = listOf(n(NoteDuration.EIGHTH), n(NoteDuration.EIGHTH), n(NoteDuration.QUARTER)) + assertEquals(listOf(0..1), StaffLayout.beamGroups(notes, beats = 4, beatType = 4)) + } + + @Test + fun `a lone eighth keeps its flag`() { + val notes = listOf(n(NoteDuration.EIGHTH), n(NoteDuration.QUARTER), n(NoteDuration.EIGHTH)) + assertEquals(emptyList(), StaffLayout.beamGroups(notes, 4, 4)) + } + + @Test + fun `beat boundary splits a run of eighths in four four`() { + // 4 crome: beat 1 = note 0-1, beat 2 = note 2-3 + val notes = List(4) { n(NoteDuration.EIGHTH) } + assertEquals(listOf(0..1, 2..3), StaffLayout.beamGroups(notes, 4, 4)) + } + + @Test + fun `six eight groups eighths in threes`() { + val notes = List(6) { n(NoteDuration.EIGHTH) } + assertEquals(listOf(0..2, 3..5), StaffLayout.beamGroups(notes, 6, 8)) + } + + @Test + fun `sixteenths beam with eighths inside the beat`() { + val notes = listOf(n(NoteDuration.EIGHTH), n(NoteDuration.SIXTEENTH), n(NoteDuration.SIXTEENTH)) + assertEquals(listOf(0..2), StaffLayout.beamGroups(notes, 4, 4)) + } + + @Test + fun `stem direction for a group follows the majority`() { + assertTrue(StaffLayout.stemUpForGroup(listOf(0, 2, 6))) // 2 su 3 sotto la linea mediana + assertTrue(!StaffLayout.stemUpForGroup(listOf(5, 6, 2))) // 2 su 3 sopra + } +``` + +- [ ] **Step 2: verifica RED** — compile error su `beamGroups` +- [ ] **Step 3: implementazione** in `StaffLayout`: + +```kotlin + /** Gruppi (indici) di note veloci consecutive che iniziano nello stesso beat. */ + fun beamGroups(notes: List, beats: Int, beatType: Int): List { + val beatLen = if (beatType == 8 && beats % 3 == 0) 1.5 else 4.0 / beatType + val groups = mutableListOf() + 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 la maggioranza delle note del gruppo sta sotto la linea mediana. */ + fun stemUpForGroup(relPositions: List): Boolean = + relPositions.count { it < 4 } * 2 >= relPositions.size +``` + +- [ ] **Step 4: verifica GREEN** — suite del file → PASS + +### Task 3: StaffLayout.measurePositions + +**Files:** come Task 1. + +- [ ] **Step 1: test fallenti** + +```kotlin + @Test + fun `barlines fall every four quarters in four four`() { + val notes = List(8) { n(NoteDuration.QUARTER) } + assertEquals(listOf(3, 7), StaffLayout.measurePositions(notes, 4, 4)) + } + + @Test + fun `barlines fall every three quarters in three four`() { + val notes = List(6) { n(NoteDuration.QUARTER) } + assertEquals(listOf(2, 5), StaffLayout.measurePositions(notes, 3, 4)) + } + + @Test + fun `overshooting note closes the measure after itself`() { + // 2/4: la semibreve (4 quarti) eccede la battuta da 2 → stanghetta dopo di lei + val notes = listOf(n(NoteDuration.WHOLE), n(NoteDuration.QUARTER), n(NoteDuration.QUARTER)) + assertEquals(listOf(0, 2), StaffLayout.measurePositions(notes, 2, 4)) + } + + @Test + fun `empty sequence has no barlines`() { + assertEquals(emptyList(), StaffLayout.measurePositions(emptyList(), 4, 4)) + } +``` + +- [ ] **Step 2: verifica RED** +- [ ] **Step 3: implementazione** + +```kotlin + /** Indici di nota DOPO i quali cade una stanghetta (inclusa l'eventuale finale). */ + fun measurePositions(notes: List, beats: Int, beatType: Int): List { + val capacity = beats * (4.0 / beatType) + if (capacity <= 0.0) return emptyList() + val positions = mutableListOf() + 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 + } +``` + +- [ ] **Step 4: verifica GREEN** + +### Task 4: StaffLayout.xPositions (spaziatura proporzionale) + +**Files:** come Task 1. + +- [ ] **Step 1: test fallenti** + +```kotlin + @Test + fun `x positions give longer notes more room`() { + val notes = listOf(n(NoteDuration.HALF), n(NoteDuration.EIGHTH), n(NoteDuration.EIGHTH)) + val xs = StaffLayout.xPositions(notes, areaStart = 0f, areaWidth = 100f) + assertEquals(3, xs.size) + val gapLongToShort = xs[1] - xs[0] + val gapShortToShort = xs[2] - xs[1] + assertTrue(gapLongToShort > gapShortToShort, "la minima deve occupare più spazio: $xs") + assertTrue(xs.all { it in 0f..100f }) + } + + @Test + fun `single note is centered in the area`() { + val xs = StaffLayout.xPositions(listOf(n(NoteDuration.QUARTER)), 10f, 100f) + assertEquals(60f, xs.single(), absoluteTolerance = 0.5f) + } +``` + +- [ ] **Step 2: verifica RED** +- [ ] **Step 3: implementazione** (peso = √durata; centro dello slot) + +```kotlin + /** Centri orizzontali proporzionali a sqrt(durata): la nota lunga respira. */ + fun xPositions(notes: List, areaStart: Float, areaWidth: Float): List { + 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 + } + } +``` + +- [ ] **Step 4: verifica GREEN** + +### Task 5: NoteSequence.beats/beatType + parser `""", + RegexOption.DOT_MATCHES_ALL, + ) +``` + +e nel `parse`, prima del `return`: + +```kotlin + 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) +``` + +- [ ] **Step 4: verifica GREEN** — suite completa desktop → PASS + +### Task 6: StaffRenderer.kt — primitive di disegno + +**Files:** +- Create: `shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffRenderer.kt` + +Non testabile a unità (DrawScope): la verifica è la compilazione + l'ispezione visiva del Task 10. Coordinate dei `Path` prese dai mockup approvati (unità `u = lineSpacing * 0.09f`, testa larga ~1.35·lineSpacing come oggi). + +- [ ] **Step 1: creare il file** con questo contenuto: + +```kotlin +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 androidx.compose.ui.unit.sp +import com.notemusicali.music.MusicalNote +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" (dal mockup approvato): 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) + quadraticBezierTo(cx - 5.4f * u, cy - 4.8f * u, cx + 2.4f * u, cy - 4.9f * u) + quadraticBezierTo(cx + 7.6f * u, cy - 4.9f * u, cx + 7.4f * u, cy - 0.9f * u) + quadraticBezierTo(cx + 6.8f * u, cy + 4.9f * u, cx - 1.6f * u, cy + 5.0f * u) + quadraticBezierTo(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: disegna 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, ys: List, + durations: List, + 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 = quartersOf(durations[i]) <= 0.25 && quartersOf(durations[i + 1]) <= 0.25 + if (bothShort) beamSegment(attachX[i], attachX[i + 1], secondOffset) + } +} + +private fun quartersOf(d: NoteDuration): Double = StaffLayout.quarterUnits(d) + +/** 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() +} +``` + +- [ ] **Step 2: compilare** — `./gradlew :shared:compileKotlinDesktop` → BUILD SUCCESSFUL + +### Task 7: riscrittura StaffView.kt (StaffView + StaffPreview) + +**Files:** +- Modify: `shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffView.kt` + +- [ ] **Step 1: StaffView (nota singola)** — sostituire il blocco head/stem/flags/accidental con: + +```kotlin + 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) } + drawAccidentalGlyph(note, noteX - staffHeadHalfWidth(lineSpacing) - lineSpacing * 0.7f, noteY, lineSpacing * 0.45f, noteColor) +``` + +e il blocco chiave con `drawClef(textMeasurer, useTreble, leftMargin + 2f, staffBottom, lineSpacing, clefColor)` (dove `clefColor` = colore note a piena opacità). + +- [ ] **Step 2: StaffPreview** — sostituire il calcolo `noteStep` uniforme con `StaffLayout.xPositions(notes, noteAreaStart, noteAreaWidth)`; calcolare `val groups = StaffLayout.beamGroups(notes, 4, 4)` e disegnare le note nei gruppi con `drawBeamedGroup` (x/y/durate del gruppo, `stemUp = StaffLayout.stemUpForGroup(relPos del gruppo)`), le altre con `drawStem`+`drawCurvedFlags`. Le teste si disegnano per tutte con `drawNoteHeadPath`. +- [ ] **Step 3: compilare + suite** — BUILD SUCCESSFUL, test verdi. + +### Task 8: riscrittura StaffContextView.kt + +**Files:** +- Modify: `shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffContextView.kt` + +- [ ] **Step 1:** firma: aggiungere parametri `beats: Int = 4, beatType: Int = 4` a `StaffContextView` (i chiamanti in PracticeScreen li passano dalla sequenza, con default invariati altrove). +- [ ] **Step 2:** dentro il Canvas: `xs = StaffLayout.xPositions(visibleNotes, noteAreaStart, noteAreaWidth)`; `groups = StaffLayout.beamGroups(visibleNotes, beats, beatType)`; `bars = StaffLayout.measurePositions(notes, beats, beatType)` (calcolate sull'intera sequenza, disegnate se l'indice assoluto cade nella finestra: `x` a metà tra la nota e la successiva). Disegno note: teste `drawNoteHeadPath` sempre; per gli indici nei gruppi raccogliere x/y/durate e chiamare `drawBeamedGroup` una volta per gruppo con il colore della nota corrente se il gruppo la contiene, altrimenti dim; per gli altri `drawStem`+`drawCurvedFlags`. Chiave con `drawClef`. Evidenziazioni (cerchio oro, etichetta) invariate. +- [ ] **Step 3:** aggiornare il call-site in `PracticeScreen` (passare `beats`/`beatType` della sequenza corrente). +- [ ] **Step 4:** compilare + suite. + +### Task 9: riscrittura StaffFullView.kt + +**Files:** +- Modify: `shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffFullView.kt` + +- [ ] **Step 1:** firma: aggiungere `beats: Int = 4, beatType: Int = 4`; call-site in PracticeScreen aggiornato. +- [ ] **Step 2:** wrapping per battute: pre-calcolare `bars = StaffLayout.measurePositions(notes, beats, beatType)` e spezzare le righe sul confine di battuta più vicino alla capienza (`notesPerLine` diventa il tetto: la riga termina all'ultima stanghetta che ci sta; se una battuta eccede da sola la riga si spezza comunque a `notesPerLine`). Ogni riga usa `StaffLayout.xPositions` sul proprio segmento, `beamGroups` calcolati per segmento di riga, tempo (4/4) disegnato con `drawTimeSignature` solo sulla prima riga dopo la chiave, `drawBarline` alle stanghette interne e a fine riga se coincide con una battuta. +- [ ] **Step 3:** compilare + suite; auto-scroll esistente invariato (usa `currentLine` = riga contenente `currentIndex`, ora derivata dalla mappa nota→riga costruita nello step 2). + +### Task 10: pulizia + verifica completa + +- [ ] **Step 1:** eliminare `NoteRenderer.kt` (drawNoteHead/drawNoteStem/drawNoteFlags non più referenziati — verificare con grep prima di cancellare). +- [ ] **Step 2:** suite completa: `./gradlew :shared:desktopTest :shared:iosSimulatorArm64Test :app:assembleDebug` → tutto verde. +- [ ] **Step 3:** lancio app desktop (`./gradlew :desktopApp:run`), ispezione visiva di Esercizi (StaffPreview), Pratica in modalità Contesto e Spartito, e Scale; passata di tuning sulle costanti (`u`, spessori, pendenza) se qualcosa stona. diff --git a/docs/superpowers/specs/2026-07-22-staff-rendering-design.md b/docs/superpowers/specs/2026-07-22-staff-rendering-design.md new file mode 100644 index 0000000..19a0410 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-staff-rendering-design.md @@ -0,0 +1,71 @@ +# Design: rendering avanzato del pentagramma (InTono) + +Data: 2026-07-22 — Approccio approvato: **disegno raffinato su Canvas** (nessun font musicale embedded). + +## Obiettivo + +Migliorare la resa delle note sul pentagramma nelle viste Compose condivise con quattro upgrade approvati dall'utente: + +1. **Glifi raffinati** — teste "a pera" (`Path`), code curve riempite, ♯/♭ ben proporzionati, chiave a piena opacità e dimensione maggiore. +2. **Travature (beam)** — crome/semicrome consecutive nello stesso beat unite da parallelogrammi inclinati. +3. **Battute** — stanghette e cifre del tempo (es. 4/4) dopo la chiave. +4. **Spaziatura proporzionale** — larghezza orizzontale ∝ √(durata in quarti). + +Non-obiettivi: font SMuFL/Bravura (rimandabile senza rework: la geometria resta valida), pause, legature di portamento, chiavi miste nello stesso rigo, gambi condivisi tra righi. + +## Architettura + +### 1. `StaffLayout` (esteso) — geometria pura, `commonMain`, testabile + +- `quarterUnits(duration: NoteDuration): Double` — WHOLE=4, HALF=2, QUARTER=1, EIGHTH=0.5, SIXTEENTH=0.25, THIRTY_SECOND=0.125. +- `beamGroups(notes: List, beats: Int, beatType: Int): List` — gruppi di indici di note beamabili (durata ≤ EIGHTH) consecutive che iniziano nello stesso beat; gruppi di 1 elemento esclusi (tengono la coda). Il confine di beat spezza il gruppo. +- `measurePositions(notes: List, beats: Int, beatType: Int): List` — indici *dopo* i quali cade una stanghetta (accumulo di quarti ≥ capacità battuta; best effort quando le durate non riempiono esattamente la battuta: la stanghetta cade al superamento, mai eccezioni). +- `spacingWeights(notes: List): List` — pesi orizzontali `sqrt(quarterUnits)`, normalizzati dal chiamante sull'ampiezza disponibile. +- `stemUp(relPos: Int): Boolean` (regola esistente `relPos < 4`); per un beam group la direzione è quella della maggioranza delle note del gruppo. + +### 2. `StaffRenderer` (nuovo file, `ui/components`) — estensioni `DrawScope` + +Primitive di disegno usate da tutte le viste: + +- `drawNoteHeadPath` — testa a pera via `Path` (quadratiche, come il mockup approvato), piena per ≤ QUARTER, contorno per WHOLE/HALF; rotazione implicita nella forma. +- `drawCurvedFlag` — coda a goccia riempita (`Path` cubica), 1–3 code per croma/semicroma/biscroma, specchiata per gambo in giù. +- `drawBeam` — parallelogramma tra due estremità di gambo, spessore ~0.5·lineSpacing, pendenza limitata (±0.5·lineSpacing per nota); doppia barra per semicrome. +- `drawSharpGlyph` / `drawFlatGlyph` — esistenti, raffinati (proporzioni del mockup). +- `drawBarline`, `drawTimeSignature(beats, beatType)` — stanghetta sottile a tutta altezza rigo; cifre con `TextMeasurer` (bold, serif di sistema). +- `drawClef` — glifo Unicode esistente ma a piena opacità/colore nota e dimensione maggiore. + +Le funzioni esistenti in `NoteRenderer.kt` (drawNoteHead/drawNoteStem/drawNoteFlags) vengono sostituite dalle nuove primitive; il file viene aggiornato o assorbito da StaffRenderer. + +### 3. Modello e parser + +- `NoteSequence` guadagna `beats: Int = 4`, `beatType: Int = 4`. +- `MusicXmlParser` estrae la prima occorrenza di `` nella prima parte; assente → 4/4. +- Inserimento manuale, scale, esercizi, scansione AI: default 4/4. + +### 4. Applicazione per vista + +| Vista | Glifi | Travature | Battute+Tempo | Spaziatura | +|---|---|---|---|---| +| StaffFullView | ✅ | ✅ | ✅ | ✅ (per rigo) | +| StaffContextView | ✅ | ✅ | ✅ (se nella finestra) | ✅ | +| StaffPreview | ✅ | ✅ | — | ✅ | +| StaffView (nota singola) | ✅ | — | — | — | + +L'evidenziazione (nota corrente oro, suonate sbiadite) resta identica. In StaffFullView il wrapping per riga passa da "N note fisse" a "capienza per pesi", con le battute che preferibilmente non si spezzano a fine riga quando possibile (best effort: si spezza sul confine di battuta più vicino che sta nella riga; se una battuta è più larga della riga, si spezza comunque). + +## Gestione errori + +- Sequenze senza time signature → 4/4. +- Durate che non riempiono la battuta (anacrusi, parser lossy) → stanghetta al superamento della soglia; nessuna eccezione. +- Beam group con note su registri lontani → pendenza della travatura clampata. +- Sequenze vuote → nessun disegno (comportamento attuale). + +## Test + +- **TDD (commonTest)**: `quarterUnits` per tutte le durate; `beamGroups` (coppie di crome, croma singola, confine di beat in 4/4 e 6/8, misto crome/semicrome, interruzione su nota lunga); `measurePositions` (4/4 esatto, 3/4, durate eccedenti, sequenza vuota); `spacingWeights` (monotonia, rapporto √); parser `""", + RegexOption.DOT_MATCHES_ALL, + ) + + fun parse(xmlContent: String, name: String = "MusicXML"): NoteSequence { + val partContent = firstPartContent(xmlContent) + val notes = mutableListOf() + var firstVoice: String? = null + + // (in ) 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 / when is absent. */ + private fun durationFromRatio( + noteBlock: String, + noteOffset: Int, + divisionsByOffset: List>, + ): 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 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("", contentStart) + return if (end >= 0) xml.substring(contentStart, end) else xml.substring(contentStart) + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/music/MusicalNote.kt b/shared/src/commonMain/kotlin/com/notemusicali/music/MusicalNote.kt new file mode 100644 index 0000000..1167235 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/music/MusicalNote.kt @@ -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, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/music/NoteSequence.kt b/shared/src/commonMain/kotlin/com/notemusicali/music/NoteSequence.kt new file mode 100644 index 0000000..ba600ea --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/music/NoteSequence.kt @@ -0,0 +1,21 @@ +package com.notemusicali.music + +/** + * Ordered list of notes for practice mode. + */ +data class NoteSequence( + val name: String, + val notes: List, + 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()) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/music/ScaleGenerator.kt b/shared/src/commonMain/kotlin/com/notemusicali/music/ScaleGenerator.kt new file mode 100644 index 0000000..d137d94 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/music/ScaleGenerator.kt @@ -0,0 +1,76 @@ +package com.notemusicali.music + +enum class ScaleType(val displayName: String, val intervals: List) { + 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) { + 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) + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/navigation/AppNavigation.kt b/shared/src/commonMain/kotlin/com/notemusicali/navigation/AppNavigation.kt new file mode 100644 index 0000000..814817d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/navigation/AppNavigation.kt @@ -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(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) + }, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/scan/ApiKeyStore.kt b/shared/src/commonMain/kotlin/com/notemusicali/scan/ApiKeyStore.kt new file mode 100644 index 0000000..490120a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/scan/ApiKeyStore.kt @@ -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) { + 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() + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/scan/AppSettings.kt b/shared/src/commonMain/kotlin/com/notemusicali/scan/AppSettings.kt new file mode 100644 index 0000000..7a9d160 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/scan/AppSettings.kt @@ -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"), + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/scan/ClaudeOmrProcessor.kt b/shared/src/commonMain/kotlin/com/notemusicali/scan/ClaudeOmrProcessor.kt new file mode 100644 index 0000000..27711e3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/scan/ClaudeOmrProcessor.kt @@ -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> { + 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> { + 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) + }) + }) + }) + }) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/scan/NoteParser.kt b/shared/src/commonMain/kotlin/com/notemusicali/scan/NoteParser.kt new file mode 100644 index 0000000..5a7ee23 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/scan/NoteParser.kt @@ -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 { + 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) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/scan/OmrHttp.kt b/shared/src/commonMain/kotlin/com/notemusicali/scan/OmrHttp.kt new file mode 100644 index 0000000..2657bba --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/scan/OmrHttp.kt @@ -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" + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/scan/OmrProcessor.kt b/shared/src/commonMain/kotlin/com/notemusicali/scan/OmrProcessor.kt new file mode 100644 index 0000000..b7075db --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/scan/OmrProcessor.kt @@ -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> +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/scan/OpenAiOmrProcessor.kt b/shared/src/commonMain/kotlin/com/notemusicali/scan/OpenAiOmrProcessor.kt new file mode 100644 index 0000000..d12efa3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/scan/OpenAiOmrProcessor.kt @@ -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> { + 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> { + 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") + }) + }) + }) + }) + }) + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/scan/PlatformImage.kt b/shared/src/commonMain/kotlin/com/notemusicali/scan/PlatformImage.kt new file mode 100644 index 0000000..0b307c8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/scan/PlatformImage.kt @@ -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 diff --git a/shared/src/commonMain/kotlin/com/notemusicali/stats/StatsCalculator.kt b/shared/src/commonMain/kotlin/com/notemusicali/stats/StatsCalculator.kt new file mode 100644 index 0000000..05894da --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/stats/StatsCalculator.kt @@ -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 = 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 = SessionRepository.load()): List { + 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 { + 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) + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/ChallengeScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/ChallengeScreen.kt new file mode 100644 index 0000000..20c50f7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/ChallengeScreen.kt @@ -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)) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/ChallengeSetupScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/ChallengeSetupScreen.kt new file mode 100644 index 0000000..9ee9a55 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/ChallengeSetupScreen.kt @@ -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) +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/ChallengeViewModel.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/ChallengeViewModel.kt new file mode 100644 index 0000000..0dd1295 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/ChallengeViewModel.kt @@ -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 = _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() + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/LeaderboardScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/LeaderboardScreen.kt new file mode 100644 index 0000000..8ecebf9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/LeaderboardScreen.kt @@ -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)) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/BackTopBar.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/BackTopBar.kt new file mode 100644 index 0000000..96ccdcc --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/BackTopBar.kt @@ -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, + ), + ) +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/FilePicker.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/FilePicker.kt new file mode 100644 index 0000000..616284f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/FilePicker.kt @@ -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, + onFileContent: (String) -> Unit, +): () -> Unit diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/FolderPicker.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/FolderPicker.kt new file mode 100644 index 0000000..7f62516 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/FolderPicker.kt @@ -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 diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/GradientCard.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/GradientCard.kt new file mode 100644 index 0000000..6d846f3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/GradientCard.kt @@ -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), + ), + ), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/KeepScreenOn.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/KeepScreenOn.kt new file mode 100644 index 0000000..81b3e51 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/KeepScreenOn.kt @@ -0,0 +1,6 @@ +package com.notemusicali.ui.components + +import androidx.compose.runtime.Composable + +@Composable +expect fun KeepScreenOn() diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffContextView.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffContextView.kt new file mode 100644 index 0000000..659b727 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffContextView.kt @@ -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, + 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, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffFullView.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffFullView.kt new file mode 100644 index 0000000..d72ec3e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffFullView.kt @@ -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, + 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() + 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) + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffLayout.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffLayout.kt new file mode 100644 index 0000000..31d53a0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffLayout.kt @@ -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): 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, beats: Int, beatType: Int): List { + if (beats <= 0 || beatType <= 0) return emptyList() + val beatLen = if (beatType == 8 && beats % 3 == 0) 1.5 else 4.0 / beatType + val groups = mutableListOf() + 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): 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, beats: Int, beatType: Int): List { + val capacity = beats * (4.0 / beatType) + if (capacity <= 0.0) return emptyList() + val positions = mutableListOf() + 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, areaStart: Float, areaWidth: Float): List { + 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) +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffRenderer.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffRenderer.kt new file mode 100644 index 0000000..9e39fbb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffRenderer.kt @@ -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, ys: List, + durations: List, + 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() +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffView.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffView.kt new file mode 100644 index 0000000..7d50bda --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffView.kt @@ -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, + 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) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/eartraining/EarTrainingScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/eartraining/EarTrainingScreen.kt new file mode 100644 index 0000000..b428eb4 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/eartraining/EarTrainingScreen.kt @@ -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)) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/eartraining/EarTrainingViewModel.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/eartraining/EarTrainingViewModel.kt new file mode 100644 index 0000000..0221450 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/eartraining/EarTrainingViewModel.kt @@ -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 = _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() + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/exercises/ExercisesScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/exercises/ExercisesScreen.kt new file mode 100644 index 0000000..76d70a9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/exercises/ExercisesScreen.kt @@ -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), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/goals/DailyGoalScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/goals/DailyGoalScreen.kt new file mode 100644 index 0000000..16a312e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/goals/DailyGoalScreen.kt @@ -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)) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/guide/GuideScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/guide/GuideScreen.kt new file mode 100644 index 0000000..969b83e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/guide/GuideScreen.kt @@ -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), + ) +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/history/HistoryScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/history/HistoryScreen.kt new file mode 100644 index 0000000..c05ff8d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/history/HistoryScreen.kt @@ -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), + ) +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/home/HomeScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/home/HomeScreen.kt new file mode 100644 index 0000000..6c47765 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/home/HomeScreen.kt @@ -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), + ) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/metronome/MetronomeScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/metronome/MetronomeScreen.kt new file mode 100644 index 0000000..8e6a481 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/metronome/MetronomeScreen.kt @@ -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), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/metronome/MetronomeViewModel.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/metronome/MetronomeViewModel.kt new file mode 100644 index 0000000..08c781a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/metronome/MetronomeViewModel.kt @@ -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 = _uiState.asStateFlow() + + // Tap tempo tracking + private val tapTimes = mutableListOf() + 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() + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/ManualInputScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/ManualInputScreen.kt new file mode 100644 index 0000000..661f6ed --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/ManualInputScreen.kt @@ -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() } + 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, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeScreen.kt new file mode 100644 index 0000000..9d5d282 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeScreen.kt @@ -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, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeSetupScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeSetupScreen.kt new file mode 100644 index 0000000..3c47c31 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeSetupScreen.kt @@ -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), + ) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeViewModel.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeViewModel.kt new file mode 100644 index 0000000..670ae28 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeViewModel.kt @@ -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 = _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() + 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() + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/reference/ReferenceNoteScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/reference/ReferenceNoteScreen.kt new file mode 100644 index 0000000..c28a4d6 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/reference/ReferenceNoteScreen.kt @@ -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)) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/scales/ScaleGeneratorScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/scales/ScaleGeneratorScreen.kt new file mode 100644 index 0000000..1069143 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/scales/ScaleGeneratorScreen.kt @@ -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)) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/scan/AiProvider.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/scan/AiProvider.kt new file mode 100644 index 0000000..d7ad140 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/scan/AiProvider.kt @@ -0,0 +1,6 @@ +package com.notemusicali.ui.scan + +enum class AiProvider(val displayName: String) { + CLAUDE("Claude (Anthropic)"), + OPENAI("GPT-4o (OpenAI)"), +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/scan/CameraCapture.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/scan/CameraCapture.kt new file mode 100644 index 0000000..0e17196 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/scan/CameraCapture.kt @@ -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, +) diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/scan/ScanScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/scan/ScanScreen.kt new file mode 100644 index 0000000..a5446d0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/scan/ScanScreen.kt @@ -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, + 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)) + } + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/scan/ScanViewModel.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/scan/ScanViewModel.kt new file mode 100644 index 0000000..4cebf62 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/scan/ScanViewModel.kt @@ -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) : ScanState() + data class Error(val message: String) : ScanState() + } + + private val _state = MutableStateFlow(ScanState.Camera) + val state: StateFlow = _state.asStateFlow() + + private val _showApiKeyDialog = MutableStateFlow(!hasApiKey()) + val showApiKeyDialog: StateFlow = _showApiKeyDialog.asStateFlow() + + private val _selectedProvider = MutableStateFlow(loadProvider()) + val selectedProvider: StateFlow = _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 + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/stats/LineChart.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/stats/LineChart.kt new file mode 100644 index 0000000..d2b5264 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/stats/LineChart.kt @@ -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, + labels: List = 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)) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/stats/StatsScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/stats/StatsScreen.kt new file mode 100644 index 0000000..ca98a4c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/stats/StatsScreen.kt @@ -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, + labels: List, + 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, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/theme/Theme.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/theme/Theme.kt new file mode 100644 index 0000000..50a746b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/theme/Theme.kt @@ -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, + ) +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/tuner/TunerScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/tuner/TunerScreen.kt new file mode 100644 index 0000000..2680ccb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/tuner/TunerScreen.kt @@ -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), + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/tuner/TunerViewModel.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/tuner/TunerViewModel.kt new file mode 100644 index 0000000..1dc0444 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/tuner/TunerViewModel.kt @@ -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 = _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() + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/util/CrashHandler.kt b/shared/src/commonMain/kotlin/com/notemusicali/util/CrashHandler.kt new file mode 100644 index 0000000..e6c90ea --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/util/CrashHandler.kt @@ -0,0 +1,8 @@ +package com.notemusicali.util + +/** + * Platform-specific crash handler for logging uncaught exceptions. + */ +expect object CrashHandler { + fun install() +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/util/TimeProvider.kt b/shared/src/commonMain/kotlin/com/notemusicali/util/TimeProvider.kt new file mode 100644 index 0000000..366a5df --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/util/TimeProvider.kt @@ -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 diff --git a/shared/src/commonTest/kotlin/com/notemusicali/audio/NoteConverterTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/audio/NoteConverterTest.kt new file mode 100644 index 0000000..f9d50d8 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/audio/NoteConverterTest.kt @@ -0,0 +1,75 @@ +package com.notemusicali.audio + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NoteConverterTest { + + @Test + fun a4at440Hz() { + val result = NoteConverter.frequencyToNote(440f) + assertEquals("LA", result.note.displayName) + assertEquals(4, result.note.octave) + assertEquals(69, result.note.midiNumber) + assertTrue(abs(result.centsDeviation) < 1f, "Cents deviation should be near 0") + } + + @Test + fun c4do4at261_63Hz() { + val result = NoteConverter.frequencyToNote(261.63f) + assertEquals("DO", result.note.displayName) + assertEquals(4, result.note.octave) + assertEquals(60, result.note.midiNumber) + assertTrue(abs(result.centsDeviation) < 2f, "Cents deviation should be near 0") + } + + @Test + fun g3sol3at196HzViolinOpenString() { + val result = NoteConverter.frequencyToNote(196f) + assertEquals("SOL", result.note.displayName) + assertEquals(3, result.note.octave) + } + + @Test + fun d4re4at293_66Hz() { + val result = NoteConverter.frequencyToNote(293.66f) + assertEquals("RE", result.note.displayName) + assertEquals(4, result.note.octave) + } + + @Test + fun e5mi5at659_26HzViolinEString() { + val result = NoteConverter.frequencyToNote(659.26f) + assertEquals("MI", result.note.displayName) + assertEquals(5, result.note.octave) + } + + @Test + fun slightlySharpA4at442HzShowsPositiveCents() { + val result = NoteConverter.frequencyToNote(442f) + assertEquals("LA", result.note.displayName) + assertTrue(result.centsDeviation > 0f, "Should be sharp (positive cents)") + } + + @Test + fun slightlyFlatA4at438HzShowsNegativeCents() { + val result = NoteConverter.frequencyToNote(438f) + assertEquals("LA", result.note.displayName) + assertTrue(result.centsDeviation < 0f, "Should be flat (negative cents)") + } + + @Test + fun midiToFrequencyAndFrequencyToNoteRoundtrip() { + for (midi in 48..84) { + val freq = NoteConverter.midiToFrequency(midi) + val result = NoteConverter.frequencyToNote(freq) + assertEquals(midi, result.note.midiNumber, "MIDI $midi should roundtrip") + assertTrue( + abs(result.centsDeviation) < 0.1f, + "Cents should be near 0 for exact MIDI frequency", + ) + } + } +} diff --git a/shared/src/commonTest/kotlin/com/notemusicali/audio/PitchDetectorTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/audio/PitchDetectorTest.kt new file mode 100644 index 0000000..3bfce4d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/audio/PitchDetectorTest.kt @@ -0,0 +1,94 @@ +package com.notemusicali.audio + +import kotlin.math.PI +import kotlin.math.sin +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PitchDetectorTest { + + private lateinit var detector: PitchDetector + private val sampleRate = 44100 + private val bufferSize = 2048 + + @BeforeTest + fun setUp() { + detector = PitchDetector( + sampleRate = sampleRate, + bufferSize = bufferSize, + threshold = 0.15f, + ) + } + + private fun generateSineWave(frequency: Float, amplitude: Float = 0.8f): FloatArray { + return FloatArray(bufferSize) { i -> + (amplitude * sin(2.0 * PI * frequency * i / sampleRate)).toFloat() + } + } + + @Test + fun detectA4at440Hz() { + repeat(3) { detector.detect(generateSineWave(440f)) } + val result = detector.detect(generateSineWave(440f)) + assertTrue(result.isValid, "Should detect a valid pitch") + assertEquals(440f, result.frequency, 5f) + } + + @Test + fun detectA3at220Hz() { + repeat(3) { detector.detect(generateSineWave(220f)) } + val result = detector.detect(generateSineWave(220f)) + assertTrue(result.isValid, "Should detect a valid pitch") + assertEquals(220f, result.frequency, 5f) + } + + @Test + fun detectE5at659HzViolinOpenString() { + repeat(3) { detector.detect(generateSineWave(659.26f)) } + val result = detector.detect(generateSineWave(659.26f)) + assertTrue(result.isValid, "Should detect a valid pitch") + assertEquals(659.26f, result.frequency, 10f) + } + + @Test + fun detectG3at196HzViolinLowestOpenString() { + repeat(3) { detector.detect(generateSineWave(196f)) } + val result = detector.detect(generateSineWave(196f)) + assertTrue(result.isValid, "Should detect a valid pitch") + assertEquals(196f, result.frequency, 5f) + } + + @Test + fun detectD4at293HzViolinOpenString() { + repeat(3) { detector.detect(generateSineWave(293.66f)) } + val result = detector.detect(generateSineWave(293.66f)) + assertTrue(result.isValid, "Should detect a valid pitch") + assertEquals(293.66f, result.frequency, 5f) + } + + @Test + fun silenceReturnsInvalidResult() { + val silence = FloatArray(bufferSize) { 0f } + val result = detector.detect(silence) + assertFalse(result.isValid, "Should not detect a pitch in silence") + assertEquals(0f, result.frequency, 0.01f) + } + + @Test + fun veryLowAmplitudeReturnsSilence() { + val quiet = generateSineWave(440f, amplitude = 0.001f) + val result = detector.detect(quiet) + assertFalse(result.isValid, "Very quiet signal should not be detected") + } + + @Test + fun resetClearsMedianFilter() { + repeat(3) { detector.detect(generateSineWave(440f)) } + detector.reset() + detector.detect(generateSineWave(440f)) + assertTrue(true) + } +} diff --git a/shared/src/commonTest/kotlin/com/notemusicali/audio/PitchResultTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/audio/PitchResultTest.kt new file mode 100644 index 0000000..392ab13 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/audio/PitchResultTest.kt @@ -0,0 +1,23 @@ +package com.notemusicali.audio + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PitchResultTest { + + @Test + fun `result emitted by detector is valid regardless of its confidence value`() { + // detect() only emits frequency > 0 for results that already passed the + // detector's configurable confidenceThreshold, so a custom-threshold + // detector (e.g. 0.5) must not be silently re-filtered by a hardcoded 0.7 + val result = PitchDetector.PitchResult(frequency = 440f, confidence = 0.5f, rmsAmplitude = 0.1f) + assertTrue(result.isValid) + } + + @Test + fun `zero frequency result is not valid`() { + val result = PitchDetector.PitchResult(frequency = 0f, confidence = 0f, rmsAmplitude = 0.001f) + assertFalse(result.isValid) + } +} diff --git a/shared/src/commonTest/kotlin/com/notemusicali/challenge/ChallengeScoringTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/challenge/ChallengeScoringTest.kt new file mode 100644 index 0000000..2aaca11 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/challenge/ChallengeScoringTest.kt @@ -0,0 +1,27 @@ +package com.notemusicali.challenge + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ChallengeScoringTest { + + @Test + fun `accuracy is ratio of first-attempt notes over notes played`() { + assertEquals(0.7f, ChallengeScoring.accuracy(notesPlayed = 10, firstAttemptNotes = 7)) + } + + @Test + fun `accuracy is 1 when every note was right on first attempt`() { + assertEquals(1.0f, ChallengeScoring.accuracy(notesPlayed = 5, firstAttemptNotes = 5)) + } + + @Test + fun `accuracy is 0 when no notes were played`() { + assertEquals(0f, ChallengeScoring.accuracy(notesPlayed = 0, firstAttemptNotes = 0)) + } + + @Test + fun `accuracy never exceeds 1 even with inconsistent counters`() { + assertEquals(1.0f, ChallengeScoring.accuracy(notesPlayed = 3, firstAttemptNotes = 4)) + } +} diff --git a/shared/src/commonTest/kotlin/com/notemusicali/metronome/MetronomeEngineTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/metronome/MetronomeEngineTest.kt new file mode 100644 index 0000000..45bf5df --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/metronome/MetronomeEngineTest.kt @@ -0,0 +1,64 @@ +package com.notemusicali.metronome + +import com.notemusicali.audio.ToneGenerator +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlin.math.abs +import kotlin.math.roundToLong +import kotlin.test.Test +import kotlin.test.assertTrue + +class MetronomeEngineTest { + + private class RecordingToneGenerator(private val nowMs: () -> Long) : ToneGenerator { + val clickTimes = mutableListOf() + override fun playTone(frequencyHz: Float, durationMs: Long) {} + override fun playClick(accent: Boolean) { + clickTimes.add(nowMs()) + } + override fun stop() {} + override fun release() {} + } + + @Test + fun `clicks stay on absolute time grid without drift`() = runTest { + val clock = { testScheduler.currentTime } + val generator = RecordingToneGenerator(clock) + val engine = MetronomeEngine(toneGenerator = generator, nowMs = clock) + engine.bpm = 137 // 60000/137 = 437.956ms: non-integer interval exposes quantization drift + + engine.start(backgroundScope) + advanceTimeBy(60_001) + engine.stop() + + assertTrue(generator.clickTimes.size in 137..139, "expected ~137 beats in 1 min, got ${generator.clickTimes.size}") + generator.clickTimes.forEachIndexed { k, t -> + val expected = (k * 60_000.0 / 137).roundToLong() + assertTrue( + abs(t - expected) <= 1, + "beat $k fired at ${t}ms, expected ~${expected}ms (drift ${t - expected}ms)", + ) + } + } + + @Test + fun `bpm change mid-run reschedules subsequent beats`() = runTest { + val clock = { testScheduler.currentTime } + val generator = RecordingToneGenerator(clock) + val engine = MetronomeEngine(toneGenerator = generator, nowMs = clock) + engine.bpm = 60 + + engine.start(backgroundScope) + advanceTimeBy(2_001) // beats at 0, 1000, 2000 + engine.bpm = 120 + advanceTimeBy(2_000) // beats every 500ms from 2500 (approximately) + engine.stop() + + val afterChange = generator.clickTimes.filter { it > 2_000 } + assertTrue(afterChange.size >= 3, "expected faster beats after bpm change, got $afterChange") + val intervals = afterChange.zipWithNext { a, b -> b - a } + intervals.forEach { interval -> + assertTrue(abs(interval - 500) <= 1, "expected ~500ms interval after bpm change, got $interval") + } + } +} diff --git a/shared/src/commonTest/kotlin/com/notemusicali/music/FlatNotationTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/music/FlatNotationTest.kt new file mode 100644 index 0000000..4f70905 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/music/FlatNotationTest.kt @@ -0,0 +1,68 @@ +package com.notemusicali.music + +import com.notemusicali.scan.NoteParser +import com.notemusicali.ui.components.StaffLayout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class FlatNotationTest { + + @Test + fun `note built with flat preference displays the flat name`() { + val note = MusicalNote.fromNameAndOctave(3, 4, preferFlat = true) + assertEquals("MIb", note.displayName) + assertEquals("MIb4", note.fullName) + } + + @Test + fun `flat preference on a natural note falls back to the natural name`() { + val note = MusicalNote.fromNameAndOctave(4, 4, preferFlat = true) + assertEquals("MI", note.displayName) + } + + @Test + fun `default preference stays sharp`() { + assertEquals("RE#", MusicalNote.fromNameAndOctave(3, 4).displayName) + } + + @Test + fun `musicxml negative alter produces a flat note`() { + val xml = """ + + B-134 + + """.trimIndent() + + val sequence = MusicXmlParser.parse(xml) + assertEquals(1, sequence.notes.size) + assertEquals("SIb", sequence.notes[0].displayName) + assertEquals(10, sequence.notes[0].noteIndex) + } + + @Test + fun `scanned note with b suffix produces a flat note`() { + val notes = NoteParser.parseNotes("Bb3\nC#4") + assertEquals(2, notes.size) + assertEquals("SIb", notes[0].displayName) + assertEquals("DO#", notes[1].displayName) + } + + @Test + fun `flat note sits on the line of the natural above it`() { + val eFlat = MusicalNote.fromNameAndOctave(3, 4, preferFlat = true) + val eNatural = MusicalNote.fromNameAndOctave(4, 4) + assertEquals(StaffLayout.diatonicPosition(eNatural), StaffLayout.diatonicPosition(eFlat)) + + val dSharp = MusicalNote.fromNameAndOctave(3, 4) + val dNatural = MusicalNote.fromNameAndOctave(2, 4) + assertEquals(StaffLayout.diatonicPosition(dNatural), StaffLayout.diatonicPosition(dSharp)) + } + + @Test + fun `accidental kind follows the note preference`() { + assertEquals(StaffLayout.Accidental.SHARP, StaffLayout.accidentalFor(MusicalNote.fromNameAndOctave(3, 4))) + assertEquals(StaffLayout.Accidental.FLAT, StaffLayout.accidentalFor(MusicalNote.fromNameAndOctave(3, 4, preferFlat = true))) + assertNull(StaffLayout.accidentalFor(MusicalNote.fromNameAndOctave(0, 4))) + } +} diff --git a/shared/src/commonTest/kotlin/com/notemusicali/music/ItalianNotationTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/music/ItalianNotationTest.kt new file mode 100644 index 0000000..cee41db --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/music/ItalianNotationTest.kt @@ -0,0 +1,112 @@ +package com.notemusicali.music + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ItalianNotationTest { + + @Test + fun all12NotesAreMapped() { + val notes = ItalianNotation.allNotes() + assertEquals(12, notes.size) + } + + @Test + fun noteIndex0IsDo() { + assertEquals("DO", ItalianNotation.fromNoteIndex(0).sharp) + } + + @Test + fun noteIndex9IsLa() { + assertEquals("LA", ItalianNotation.fromNoteIndex(9).sharp) + } + + @Test + fun noteIndex11IsSi() { + assertEquals("SI", ItalianNotation.fromNoteIndex(11).sharp) + } + + @Test + fun sharpAndFlatVariantsExistForAccidentals() { + val cSharp = ItalianNotation.fromNoteIndex(1) + assertEquals("DO#", cSharp.sharp) + assertEquals("REb", cSharp.flat) + + val fSharp = ItalianNotation.fromNoteIndex(6) + assertEquals("FA#", fSharp.sharp) + assertEquals("SOLb", fSharp.flat) + } + + @Test + fun naturalNotesHaveNoFlatVariant() { + assertNull(ItalianNotation.fromNoteIndex(0).flat) + assertNull(ItalianNotation.fromNoteIndex(2).flat) + assertNull(ItalianNotation.fromNoteIndex(4).flat) + assertNull(ItalianNotation.fromNoteIndex(5).flat) + assertNull(ItalianNotation.fromNoteIndex(7).flat) + assertNull(ItalianNotation.fromNoteIndex(9).flat) + assertNull(ItalianNotation.fromNoteIndex(11).flat) + } + + @Test + fun toNoteIndexParsesSharpNames() { + assertEquals(0, ItalianNotation.toNoteIndex("DO")) + assertEquals(1, ItalianNotation.toNoteIndex("DO#")) + assertEquals(7, ItalianNotation.toNoteIndex("SOL")) + assertEquals(9, ItalianNotation.toNoteIndex("LA")) + } + + @Test + fun toNoteIndexParsesFlatNames() { + assertEquals(1, ItalianNotation.toNoteIndex("REb")) + assertEquals(3, ItalianNotation.toNoteIndex("MIb")) + assertEquals(8, ItalianNotation.toNoteIndex("LAb")) + assertEquals(10, ItalianNotation.toNoteIndex("SIb")) + } + + @Test + fun toNoteIndexIsCaseInsensitive() { + assertEquals(0, ItalianNotation.toNoteIndex("do")) + assertEquals(7, ItalianNotation.toNoteIndex("sol")) + assertEquals(1, ItalianNotation.toNoteIndex("do#")) + } + + @Test + fun toNoteIndexReturnsNullForInvalidNames() { + assertNull(ItalianNotation.toNoteIndex("XYZ")) + assertNull(ItalianNotation.toNoteIndex("")) + } + + @Test + fun fromMusicXmlStepMapsCorrectly() { + assertEquals(0, ItalianNotation.fromMusicXmlStep("C")) + assertEquals(2, ItalianNotation.fromMusicXmlStep("D")) + assertEquals(4, ItalianNotation.fromMusicXmlStep("E")) + assertEquals(5, ItalianNotation.fromMusicXmlStep("F")) + assertEquals(7, ItalianNotation.fromMusicXmlStep("G")) + assertEquals(9, ItalianNotation.fromMusicXmlStep("A")) + assertEquals(11, ItalianNotation.fromMusicXmlStep("B")) + } + + @Test + fun fromMusicXmlStepWithAlterHandlesSharpsAndFlats() { + assertEquals(1, ItalianNotation.fromMusicXmlStep("C", 1)) + assertEquals(11, ItalianNotation.fromMusicXmlStep("C", -1)) + assertEquals(6, ItalianNotation.fromMusicXmlStep("G", -1)) + } + + @Test + fun fromMusicXmlStepReturnsNullForInvalidStep() { + assertNull(ItalianNotation.fromMusicXmlStep("X")) + } + + @Test + fun indexWrapsCorrectly() { + assertEquals( + ItalianNotation.fromNoteIndex(0).sharp, + ItalianNotation.fromNoteIndex(12).sharp, + ) + } +} diff --git a/shared/src/commonTest/kotlin/com/notemusicali/music/MusicXmlParserTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/music/MusicXmlParserTest.kt new file mode 100644 index 0000000..d748fa9 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/music/MusicXmlParserTest.kt @@ -0,0 +1,288 @@ +package com.notemusicali.music + +import kotlin.test.Test +import kotlin.test.assertEquals + +class MusicXmlParserTest { + + @Test + fun parseSimpleMusicXmlWithTwoNotes() { + val xml = """ + + + + + + + C + 4 + + 4 + + + + E + 4 + + 4 + + + + + """.trimIndent() + + val sequence = MusicXmlParser.parse(xml) + assertEquals(2, sequence.notes.size) + assertEquals("DO", sequence.notes[0].displayName) + assertEquals(4, sequence.notes[0].octave) + assertEquals("MI", sequence.notes[1].displayName) + assertEquals(4, sequence.notes[1].octave) + } + + @Test + fun parseMusicXmlWithSharpNotes() { + val xml = """ + + + + + + + F + 1 + 4 + + 4 + + + + + """.trimIndent() + + val sequence = MusicXmlParser.parse(xml) + assertEquals(1, sequence.notes.size) + assertEquals("FA#", sequence.notes[0].displayName) + } + + @Test + fun parseMusicXmlSkipsRests() { + val xml = """ + + + + + + + A + 4 + + 4 + + + + 4 + + + + B + 4 + + 4 + + + + + """.trimIndent() + + val sequence = MusicXmlParser.parse(xml) + assertEquals(2, sequence.notes.size) + assertEquals("LA", sequence.notes[0].displayName) + assertEquals("SI", sequence.notes[1].displayName) + } + + @Test + fun parseTakesOnlyFirstPartOfMultiPartScore() { + val xml = """ + + + + + C44 + + + + + G24 + + + + """.trimIndent() + + val sequence = MusicXmlParser.parse(xml) + assertEquals(1, sequence.notes.size) + assertEquals("DO", sequence.notes[0].displayName) + } + + @Test + fun parseKeepsOnlyFirstNoteOfChord() { + val xml = """ + + C44 + E44 + G44 + D44 + + """.trimIndent() + + val sequence = MusicXmlParser.parse(xml) + assertEquals(2, sequence.notes.size) + assertEquals("DO", sequence.notes[0].displayName) + assertEquals("RE", sequence.notes[1].displayName) + } + + @Test + fun parseKeepsOnlyFirstVoice() { + val xml = """ + + C541 + D541 + 8 + C382 + + """.trimIndent() + + val sequence = MusicXmlParser.parse(xml) + assertEquals(2, sequence.notes.size) + assertEquals(5, sequence.notes[0].octave) + assertEquals(5, sequence.notes[1].octave) + } + + @Test + fun parseSkipsGraceNotes() { + val xml = """ + + B4 + C54 + + """.trimIndent() + + val sequence = MusicXmlParser.parse(xml) + assertEquals(1, sequence.notes.size) + assertEquals("DO", sequence.notes[0].displayName) + } + + @Test + fun parseSkipsTieContinuations() { + val xml = """ + + C44 + C44 + D44 + + """.trimIndent() + + val sequence = MusicXmlParser.parse(xml) + assertEquals(2, sequence.notes.size) + assertEquals("DO", sequence.notes[0].displayName) + assertEquals("RE", sequence.notes[1].displayName) + } + + @Test + fun parseSkipsRestsWithAttributes() { + val xml = """ + + 16 + E44 + + """.trimIndent() + + val sequence = MusicXmlParser.parse(xml) + assertEquals(1, sequence.notes.size) + assertEquals("MI", sequence.notes[0].displayName) + } + + @Test + fun parseDerivesDurationFromDivisionsWhenTypeIsMissing() { + val xml = """ + + 2 + C48 + D42 + E41 + + """.trimIndent() + + val sequence = MusicXmlParser.parse(xml) + assertEquals(3, sequence.notes.size) + assertEquals(NoteDuration.WHOLE, sequence.notes[0].duration) // 8/2 = 4 quarters + assertEquals(NoteDuration.QUARTER, sequence.notes[1].duration) // 2/2 = 1 quarter + assertEquals(NoteDuration.EIGHTH, sequence.notes[2].duration) // 1/2 = half quarter + } + + @Test + fun parseExplicitTypeWinsOverDurationRatio() { + val xml = """ + + 2 + C48half + + """.trimIndent() + + val sequence = MusicXmlParser.parse(xml) + assertEquals(NoteDuration.HALF, sequence.notes[0].duration) + } + + @Test + fun parseEmptyMusicXmlReturnsEmptySequence() { + val xml = """ + + + + + + + + """.trimIndent() + + val sequence = MusicXmlParser.parse(xml) + assertEquals(0, sequence.notes.size) + } + + @Test + fun parseExtractsTimeSignature() { + val xml = """ + + + C44 + + """.trimIndent() + val sequence = MusicXmlParser.parse(xml) + assertEquals(3, sequence.beats) + assertEquals(4, sequence.beatType) + } + + @Test + fun parseDefaultsToFourFourWithoutTimeSignature() { + val xml = """ + + C44 + + """.trimIndent() + val sequence = MusicXmlParser.parse(xml) + assertEquals(4, sequence.beats) + assertEquals(4, sequence.beatType) + } + + @Test + fun parseExtractsTimeSignatureWithAttributes() { + val xml = """ + + + C44 + + """.trimIndent() + val sequence = MusicXmlParser.parse(xml) + assertEquals(4, sequence.beats) + assertEquals(4, sequence.beatType) + } +} diff --git a/shared/src/commonTest/kotlin/com/notemusicali/scan/ApiKeyStoreTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/scan/ApiKeyStoreTest.kt new file mode 100644 index 0000000..57309cc --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/scan/ApiKeyStoreTest.kt @@ -0,0 +1,61 @@ +package com.notemusicali.scan + +import com.russhwolf.settings.MapSettings +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ApiKeyStoreTest { + + @Test + fun `api key is written to the secure store when available`() { + val plain = MapSettings() + val secure = MapSettings() + val store = ApiKeyStore(plain, secure) + + store.putString("anthropic_api_key", "sk-ant-secret") + + assertEquals("sk-ant-secret", secure.getStringOrNull("anthropic_api_key")) + assertNull(plain.getStringOrNull("anthropic_api_key"), "the plain store must not receive the key") + assertEquals("sk-ant-secret", store.getString("anthropic_api_key")) + } + + @Test + fun `legacy obfuscated key is migrated to the secure store on read`() { + val plain = MapSettings() + val legacyStore = ApiKeyStore(plain, secure = null) + legacyStore.putString("openai_api_key", "sk-legacy") + + val secure = MapSettings() + val store = ApiKeyStore(plain, secure) + + assertEquals("sk-legacy", store.getString("openai_api_key")) + assertEquals("sk-legacy", secure.getStringOrNull("openai_api_key")) + assertNull(plain.getStringOrNull("openai_api_key"), "legacy copy must be removed after migration") + } + + @Test + fun `without a secure store keys stay obfuscated in the plain store`() { + val plain = MapSettings() + val store = ApiKeyStore(plain, secure = null) + + store.putString("anthropic_api_key", "sk-fallback") + + val raw = plain.getStringOrNull("anthropic_api_key") + assertTrue(raw != null && raw.startsWith("OBF:") && !raw.contains("sk-fallback")) + assertEquals("sk-fallback", store.getString("anthropic_api_key")) + } + + @Test + fun `non sensitive keys stay in the plain store even when secure exists`() { + val plain = MapSettings() + val secure = MapSettings() + val store = ApiKeyStore(plain, secure) + + store.putString("scores_folder_uri", "/some/path") + + assertNull(secure.getStringOrNull("scores_folder_uri")) + assertEquals("/some/path", store.getString("scores_folder_uri")) + } +} diff --git a/shared/src/commonTest/kotlin/com/notemusicali/scan/OmrResponseParsingTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/scan/OmrResponseParsingTest.kt new file mode 100644 index 0000000..c45e658 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/scan/OmrResponseParsingTest.kt @@ -0,0 +1,62 @@ +package com.notemusicali.scan + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class OmrResponseParsingTest { + + @Test + fun `claude response with note list is parsed into notes`() { + val body = """{"content":[{"type":"text","text":"C4\nD#5\nBb3"}]}""" + val result = ClaudeOmrProcessor.parseResponse(body) + assertTrue(result.isSuccess, "expected success, got $result") + val notes = result.getOrThrow() + assertEquals(3, notes.size) + assertEquals("DO", notes[0].displayName) + assertEquals(4, notes[0].octave) + assertEquals("RE#", notes[1].displayName) + assertEquals("SIb", notes[2].displayName) // "Bb" keeps its flat spelling + } + + @Test + fun `claude response with NESSUNA_NOTA fails with explanatory message`() { + val body = """{"content":[{"type":"text","text":"NESSUNA_NOTA"}]}""" + val result = ClaudeOmrProcessor.parseResponse(body) + assertTrue(result.isFailure) + } + + @Test + fun `claude malformed response fails instead of crashing`() { + val result = ClaudeOmrProcessor.parseResponse("not json at all") + assertTrue(result.isFailure) + } + + @Test + fun `openai response with note list is parsed into notes`() { + val body = """{"choices":[{"message":{"role":"assistant","content":"E4\nF#4"}}]}""" + val result = OpenAiOmrProcessor.parseResponse(body) + assertTrue(result.isSuccess, "expected success, got $result") + val notes = result.getOrThrow() + assertEquals(2, notes.size) + assertEquals("MI", notes[0].displayName) + assertEquals("FA#", notes[1].displayName) + } + + @Test + fun `openai empty choices fails gracefully`() { + val result = OpenAiOmrProcessor.parseResponse("""{"choices":[]}""") + assertTrue(result.isFailure) + } + + @Test + fun `error message is extracted from api error body`() { + val body = """{"error":{"type":"invalid_request_error","message":"invalid x-api-key"}}""" + assertEquals("invalid x-api-key", OmrErrorParsing.errorMessage(body, statusCode = 401)) + } + + @Test + fun `error message falls back to http status for unparseable body`() { + assertEquals("Errore HTTP 500", OmrErrorParsing.errorMessage("oops", statusCode = 500)) + } +} diff --git a/shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffGeometryTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffGeometryTest.kt new file mode 100644 index 0000000..7bd5282 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffGeometryTest.kt @@ -0,0 +1,123 @@ +package com.notemusicali.ui.components + +import com.notemusicali.music.MusicalNote +import com.notemusicali.music.NoteDuration +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class StaffGeometryTest { + @Test + fun `quarter units follow the binary duration ladder`() { + assertEquals(4.0, StaffLayout.quarterUnits(NoteDuration.WHOLE)) + assertEquals(2.0, StaffLayout.quarterUnits(NoteDuration.HALF)) + assertEquals(1.0, StaffLayout.quarterUnits(NoteDuration.QUARTER)) + assertEquals(0.5, StaffLayout.quarterUnits(NoteDuration.EIGHTH)) + assertEquals(0.25, StaffLayout.quarterUnits(NoteDuration.SIXTEENTH)) + assertEquals(0.125, StaffLayout.quarterUnits(NoteDuration.THIRTY_SECOND)) + } + + private fun n(d: NoteDuration) = MusicalNote.fromNameAndOctave(0, 4, d) + + @Test + fun `consecutive eighths in the same beat are beamed together`() { + val notes = listOf(n(NoteDuration.EIGHTH), n(NoteDuration.EIGHTH), n(NoteDuration.QUARTER)) + assertEquals(listOf(0..1), StaffLayout.beamGroups(notes, beats = 4, beatType = 4)) + } + + @Test + fun `a lone eighth keeps its flag`() { + val notes = listOf(n(NoteDuration.EIGHTH), n(NoteDuration.QUARTER), n(NoteDuration.EIGHTH)) + assertEquals(emptyList(), StaffLayout.beamGroups(notes, 4, 4)) + } + + @Test + fun `beat boundary splits a run of eighths in four four`() { + val notes = List(4) { n(NoteDuration.EIGHTH) } + assertEquals(listOf(0..1, 2..3), StaffLayout.beamGroups(notes, 4, 4)) + } + + @Test + fun `six eight groups eighths in threes`() { + val notes = List(6) { n(NoteDuration.EIGHTH) } + assertEquals(listOf(0..2, 3..5), StaffLayout.beamGroups(notes, 6, 8)) + } + + @Test + fun `sixteenths beam with eighths inside the beat`() { + val notes = listOf(n(NoteDuration.EIGHTH), n(NoteDuration.SIXTEENTH), n(NoteDuration.SIXTEENTH)) + assertEquals(listOf(0..2), StaffLayout.beamGroups(notes, 4, 4)) + } + + @Test + fun `stem direction for a group follows the majority`() { + assertTrue(StaffLayout.stemUpForGroup(listOf(0, 2, 6))) + assertFalse(StaffLayout.stemUpForGroup(listOf(5, 6, 2))) + } + + @Test + fun `stem direction tie goes stem up`() { + assertTrue(StaffLayout.stemUpForGroup(listOf(2, 6))) + } + + @Test + fun `degenerate meter yields no beam groups`() { + assertEquals(emptyList(), StaffLayout.beamGroups(List(4) { n(NoteDuration.EIGHTH) }, beats = 0, beatType = 0)) + } + + @Test + fun `empty sequence has no beam groups`() { + assertEquals(emptyList(), StaffLayout.beamGroups(emptyList(), 4, 4)) + } + + @Test + fun `empty group defaults to stem up`() { + assertTrue(StaffLayout.stemUpForGroup(emptyList())) + } + + @Test + fun `degenerate meter yields no barlines`() { + assertEquals(emptyList(), StaffLayout.measurePositions(List(4) { n(NoteDuration.QUARTER) }, beats = 0, beatType = 4)) + } + + @Test + fun `barlines fall every four quarters in four four`() { + val notes = List(8) { n(NoteDuration.QUARTER) } + assertEquals(listOf(3, 7), StaffLayout.measurePositions(notes, 4, 4)) + } + + @Test + fun `barlines fall every three quarters in three four`() { + val notes = List(6) { n(NoteDuration.QUARTER) } + assertEquals(listOf(2, 5), StaffLayout.measurePositions(notes, 3, 4)) + } + + @Test + fun `overshooting note closes the measure after itself`() { + val notes = listOf(n(NoteDuration.WHOLE), n(NoteDuration.QUARTER), n(NoteDuration.QUARTER)) + assertEquals(listOf(0, 2), StaffLayout.measurePositions(notes, 2, 4)) + } + + @Test + fun `empty sequence has no barlines`() { + assertEquals(emptyList(), StaffLayout.measurePositions(emptyList(), 4, 4)) + } + + @Test + fun `x positions give longer notes more room`() { + val notes = listOf(n(NoteDuration.HALF), n(NoteDuration.EIGHTH), n(NoteDuration.EIGHTH)) + val xs = StaffLayout.xPositions(notes, areaStart = 0f, areaWidth = 100f) + assertEquals(3, xs.size) + val gapLongToShort = xs[1] - xs[0] + val gapShortToShort = xs[2] - xs[1] + assertTrue(gapLongToShort > gapShortToShort, "la minima deve occupare più spazio: $xs") + assertTrue(xs.all { it in 0f..100f }) + } + + @Test + fun `single note is centered in the area`() { + val xs = StaffLayout.xPositions(listOf(n(NoteDuration.QUARTER)), 10f, 100f) + assertEquals(60f, xs.single(), absoluteTolerance = 0.5f) + } +} diff --git a/shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffLayoutTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffLayoutTest.kt new file mode 100644 index 0000000..20e321f --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffLayoutTest.kt @@ -0,0 +1,56 @@ +package com.notemusicali.ui.components + +import com.notemusicali.music.MusicalNote +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class StaffLayoutTest { + + private fun note(name: String, octave: Int): MusicalNote { + val index = when (name) { + "DO" -> 0; "DO#" -> 1; "RE" -> 2; "MI" -> 4; "FA" -> 5 + "SOL" -> 7; "LA" -> 9; "SI" -> 11 + else -> error("unknown $name") + } + return MusicalNote.fromNameAndOctave(index, octave) + } + + @Test + fun `diatonic position maps chromatic notes onto seven steps per octave`() { + assertEquals(28, StaffLayout.diatonicPosition(note("DO", 4))) + assertEquals(28, StaffLayout.diatonicPosition(note("DO#", 4))) // sharp shares the line of its natural + assertEquals(34, StaffLayout.diatonicPosition(note("SI", 4))) + assertEquals(35, StaffLayout.diatonicPosition(note("DO", 5))) + } + + @Test + fun `treble clef is chosen from octave 3 up and bass below`() { + assertTrue(StaffLayout.useTrebleClef(note("SOL", 3))) + assertFalse(StaffLayout.useTrebleClef(note("DO", 2))) + assertTrue(StaffLayout.useTrebleClef(listOf(note("DO", 2), note("SOL", 3)))) + assertFalse(StaffLayout.useTrebleClef(listOf(note("DO", 2), note("MI", 2)))) + } + + @Test + fun `relative position puts E4 on the bottom line of the treble staff`() { + // E4 (MI4) sits exactly on the first line of the treble staff → relPos 0 + assertEquals(0, StaffLayout.relativePosition(note("MI", 4), useTreble = true)) + // G2 (SOL2) sits on the first line of the bass staff → relPos 0 + assertEquals(0, StaffLayout.relativePosition(note("SOL", 2), useTreble = false)) + } + + @Test + fun `only black-key notes need an accidental`() { + assertTrue(StaffLayout.needsSharp(note("DO#", 4))) + assertFalse(StaffLayout.needsSharp(note("DO", 4))) + assertFalse(StaffLayout.needsSharp(note("FA", 4))) + } + + @Test + fun `clef symbols are the musical unicode glyphs`() { + assertEquals("𝄞", StaffLayout.clefSymbol(useTreble = true)) + assertEquals("𝄢", StaffLayout.clefSymbol(useTreble = false)) + } +} diff --git a/shared/src/desktopMain/kotlin/com/notemusicali/audio/AudioSource.desktop.kt b/shared/src/desktopMain/kotlin/com/notemusicali/audio/AudioSource.desktop.kt new file mode 100644 index 0000000..33dc61b --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/notemusicali/audio/AudioSource.desktop.kt @@ -0,0 +1,62 @@ +package com.notemusicali.audio + +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.isActive +import javax.sound.sampled.AudioFormat +import javax.sound.sampled.AudioSystem +import javax.sound.sampled.TargetDataLine + +private class DesktopAudioSource( + private val sampleRate: Int, + private val bufferSize: Int, +) : AudioSource { + override fun audioFlow(): Flow = callbackFlow { + val format = AudioFormat( + sampleRate.toFloat(), + 16, // 16-bit samples + 1, // mono + true, // signed + false, // little-endian + ) + + val line: TargetDataLine = try { + val dataLineInfo = javax.sound.sampled.DataLine.Info(TargetDataLine::class.java, format) + AudioSystem.getLine(dataLineInfo) as TargetDataLine + } catch (e: Exception) { + close(IllegalStateException("No audio input available: ${e.message}")) + return@callbackFlow + } + + try { + line.open(format, bufferSize * 2) + line.start() + + val byteBuffer = ByteArray(bufferSize * 2) // 16-bit = 2 bytes per sample + + while (isActive) { + val bytesRead = line.read(byteBuffer, 0, byteBuffer.size) + if (bytesRead > 0) { + val samplesRead = bytesRead / 2 + val floatBuffer = FloatArray(samplesRead) + for (i in 0 until samplesRead) { + val low = byteBuffer[i * 2].toInt() and 0xFF + val high = byteBuffer[i * 2 + 1].toInt() + val sample = (high shl 8) or low + floatBuffer[i] = sample / 32768f + } + trySend(floatBuffer) + } + } + } finally { + line.stop() + line.close() + } + + awaitClose() + } +} + +actual fun createAudioSource(sampleRate: Int, bufferSize: Int): AudioSource = + DesktopAudioSource(sampleRate, bufferSize) diff --git a/shared/src/desktopMain/kotlin/com/notemusicali/audio/ToneGenerator.desktop.kt b/shared/src/desktopMain/kotlin/com/notemusicali/audio/ToneGenerator.desktop.kt new file mode 100644 index 0000000..4613b16 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/notemusicali/audio/ToneGenerator.desktop.kt @@ -0,0 +1,67 @@ +package com.notemusicali.audio + +import javax.sound.sampled.AudioFormat +import javax.sound.sampled.AudioSystem + +private class DesktopToneGenerator( + private val sampleRate: Int, +) : ToneGenerator { + + private var currentLine: javax.sound.sampled.SourceDataLine? = null + private var playThread: Thread? = null + + override fun playTone(frequencyHz: Float, durationMs: Long) { + stop() + val floatSamples = WaveGenerator.generateToneSamples(frequencyHz, durationMs, sampleRate) + playFloatSamples(floatSamples) + } + + override fun playClick(accent: Boolean) { + stop() + val floatSamples = WaveGenerator.generateClickSamples(accent, sampleRate) + playFloatSamples(floatSamples) + } + + private fun playFloatSamples(floatSamples: FloatArray) { + val format = AudioFormat(sampleRate.toFloat(), 16, 1, true, false) + val buffer = ByteArray(floatSamples.size * 2) + + for (i in floatSamples.indices) { + val value = (floatSamples[i] * Short.MAX_VALUE).toInt() + .coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort() + buffer[i * 2] = (value.toInt() and 0xFF).toByte() + buffer[i * 2 + 1] = ((value.toInt() shr 8) and 0xFF).toByte() + } + + playThread = Thread { + try { + val line = AudioSystem.getSourceDataLine(format) + line.open(format) + currentLine = line + line.start() + line.write(buffer, 0, buffer.size) + line.drain() + line.close() + } catch (_: Exception) { } + }.apply { isDaemon = true; start() } + } + + override fun stop() { + currentLine?.let { line -> + try { + line.stop() + line.close() + } catch (_: Exception) { } + } + currentLine = null + playThread?.interrupt() + playThread = null + } + + override fun release() { + stop() + } +} + +actual fun createToneGenerator(sampleRate: Int): ToneGenerator = + DesktopToneGenerator(sampleRate) diff --git a/shared/src/desktopMain/kotlin/com/notemusicali/scan/PlatformImage.desktop.kt b/shared/src/desktopMain/kotlin/com/notemusicali/scan/PlatformImage.desktop.kt new file mode 100644 index 0000000..28ca927 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/notemusicali/scan/PlatformImage.desktop.kt @@ -0,0 +1,26 @@ +package com.notemusicali.scan + +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import javax.imageio.ImageIO + +actual typealias PlatformImage = BufferedImage + +actual fun PlatformImage.toBase64Jpeg(maxDim: Int, quality: Int): String { + val scaled = if (width > maxDim || height > maxDim) { + val scale = maxDim.toDouble() / maxOf(width, height) + val newW = (width * scale).toInt() + val newH = (height * scale).toInt() + val img = BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB) + val g = img.createGraphics() + g.drawImage(this, 0, 0, newW, newH, null) + g.dispose() + img + } else { + this + } + + val stream = ByteArrayOutputStream() + ImageIO.write(scaled, "JPEG", stream) + return java.util.Base64.getEncoder().encodeToString(stream.toByteArray()) +} diff --git a/shared/src/desktopMain/kotlin/com/notemusicali/scan/SecureSettings.desktop.kt b/shared/src/desktopMain/kotlin/com/notemusicali/scan/SecureSettings.desktop.kt new file mode 100644 index 0000000..a4c6c38 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/notemusicali/scan/SecureSettings.desktop.kt @@ -0,0 +1,6 @@ +package com.notemusicali.scan + +import com.russhwolf.settings.Settings + +// No OS-agnostic secure store on desktop: keys stay in the obfuscated store +internal actual fun createSecureSettings(): Settings? = null diff --git a/shared/src/desktopMain/kotlin/com/notemusicali/ui/components/FilePicker.desktop.kt b/shared/src/desktopMain/kotlin/com/notemusicali/ui/components/FilePicker.desktop.kt new file mode 100644 index 0000000..49944a1 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/notemusicali/ui/components/FilePicker.desktop.kt @@ -0,0 +1,37 @@ +package com.notemusicali.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import com.notemusicali.scores.extractMusicXmlFromBytes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.swing.JFileChooser +import javax.swing.filechooser.FileNameExtensionFilter + +@Composable +actual fun rememberFilePickerLauncher( + mimeTypes: List, + onFileContent: (String) -> Unit, +): () -> Unit { + val scope = rememberCoroutineScope() + return { + scope.launch(Dispatchers.Default) { + val chooser = JFileChooser().apply { + fileFilter = FileNameExtensionFilter("MusicXML files", "xml", "musicxml", "mxl") + dialogTitle = "Seleziona file MusicXML" + } + val result = chooser.showOpenDialog(null) + if (result == JFileChooser.APPROVE_OPTION) { + try { + val bytes = chooser.selectedFile.readBytes() + val content = extractMusicXmlFromBytes(bytes) + if (content != null) { + onFileContent(content) + } + } catch (_: Exception) { + // File read failed + } + } + } + } +} diff --git a/shared/src/desktopMain/kotlin/com/notemusicali/ui/components/FolderPicker.desktop.kt b/shared/src/desktopMain/kotlin/com/notemusicali/ui/components/FolderPicker.desktop.kt new file mode 100644 index 0000000..d47cf5d --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/notemusicali/ui/components/FolderPicker.desktop.kt @@ -0,0 +1,20 @@ +package com.notemusicali.ui.components + +import androidx.compose.runtime.Composable +import javax.swing.JFileChooser + +@Composable +actual fun rememberFolderPickerLauncher( + onFolderSelected: (uri: String, displayName: String) -> Unit, +): () -> Unit { + return { + val chooser = JFileChooser().apply { + fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + dialogTitle = "Scegli cartella spartiti" + } + if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { + val file = chooser.selectedFile + onFolderSelected(file.absolutePath, file.name) + } + } +} diff --git a/shared/src/desktopMain/kotlin/com/notemusicali/ui/components/KeepScreenOn.desktop.kt b/shared/src/desktopMain/kotlin/com/notemusicali/ui/components/KeepScreenOn.desktop.kt new file mode 100644 index 0000000..397910f --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/notemusicali/ui/components/KeepScreenOn.desktop.kt @@ -0,0 +1,8 @@ +package com.notemusicali.ui.components + +import androidx.compose.runtime.Composable + +@Composable +actual fun KeepScreenOn() { + // No-op on desktop +} diff --git a/shared/src/desktopMain/kotlin/com/notemusicali/ui/scan/CameraCapture.desktop.kt b/shared/src/desktopMain/kotlin/com/notemusicali/ui/scan/CameraCapture.desktop.kt new file mode 100644 index 0000000..a750804 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/notemusicali/ui/scan/CameraCapture.desktop.kt @@ -0,0 +1,76 @@ +package com.notemusicali.ui.scan + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.notemusicali.scan.PlatformImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.imageio.ImageIO +import javax.swing.JFileChooser +import javax.swing.filechooser.FileNameExtensionFilter + +@Composable +actual fun CameraCapture( + onImageCaptured: (PlatformImage) -> Unit, + modifier: Modifier, +) { + val scope = rememberCoroutineScope() + + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(24.dp), + ) { + Text( + text = "Su desktop non c'è la fotocamera: scegli un'immagine dello spartito dal disco", + fontSize = 16.sp, + color = Color.White.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + ) + Button( + onClick = { + scope.launch(Dispatchers.Default) { + val image = pickImageFile() ?: return@launch + withContext(Dispatchers.Main) { onImageCaptured(image) } + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFFD700), + contentColor = Color(0xFF1A0533), + ), + ) { + Text("Scegli immagine…") + } + } + } +} + +private fun pickImageFile(): PlatformImage? { + val chooser = JFileChooser().apply { + dialogTitle = "Scegli l'immagine dello spartito" + fileFilter = FileNameExtensionFilter("Immagini (PNG, JPEG)", "png", "jpg", "jpeg") + isMultiSelectionEnabled = false + } + if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) return null + return try { + ImageIO.read(chooser.selectedFile) + } catch (_: Exception) { + null + } +} diff --git a/shared/src/desktopMain/kotlin/com/notemusicali/util/CrashHandler.desktop.kt b/shared/src/desktopMain/kotlin/com/notemusicali/util/CrashHandler.desktop.kt new file mode 100644 index 0000000..9c2efc5 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/notemusicali/util/CrashHandler.desktop.kt @@ -0,0 +1,10 @@ +package com.notemusicali.util + +actual object CrashHandler { + actual fun install() { + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + System.err.println("Uncaught exception in ${thread.name}: ${throwable.message}") + throwable.printStackTrace() + } + } +} diff --git a/shared/src/desktopMain/kotlin/com/notemusicali/util/TimeProvider.desktop.kt b/shared/src/desktopMain/kotlin/com/notemusicali/util/TimeProvider.desktop.kt new file mode 100644 index 0000000..e020ca2 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/notemusicali/util/TimeProvider.desktop.kt @@ -0,0 +1,18 @@ +package com.notemusicali.util + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +actual fun currentTimeMillis(): Long = System.currentTimeMillis() + +actual fun formatEpochDate(epochMillis: Long): String { + val sdf = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault()) + return sdf.format(Date(epochMillis)) +} + +actual fun currentDateString(): String { + // Machine-readable format parsed by DailyGoalManager: must stay ASCII in every locale + val sdf = SimpleDateFormat("yyyyMMdd", Locale.US) + return sdf.format(Date()) +} diff --git a/shared/src/desktopTest/kotlin/com/notemusicali/util/TimeProviderLocaleTest.kt b/shared/src/desktopTest/kotlin/com/notemusicali/util/TimeProviderLocaleTest.kt new file mode 100644 index 0000000..837970f --- /dev/null +++ b/shared/src/desktopTest/kotlin/com/notemusicali/util/TimeProviderLocaleTest.kt @@ -0,0 +1,24 @@ +package com.notemusicali.util + +import java.util.Locale +import kotlin.test.Test +import kotlin.test.assertTrue + +class TimeProviderLocaleTest { + + @Test + fun `currentDateString returns 8 ASCII digits regardless of default locale`() { + val original = Locale.getDefault() + try { + // Arabic (Egypt) formats numbers with Eastern Arabic numerals by default + Locale.setDefault(Locale("ar", "EG")) + val date = currentDateString() + assertTrue( + date.length == 8 && date.all { it in '0'..'9' }, + "expected 8 ASCII digits, got \"$date\"", + ) + } finally { + Locale.setDefault(original) + } + } +} diff --git a/shared/src/iosMain/kotlin/com/notemusicali/MainViewController.kt b/shared/src/iosMain/kotlin/com/notemusicali/MainViewController.kt new file mode 100644 index 0000000..85e44d6 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/notemusicali/MainViewController.kt @@ -0,0 +1,24 @@ +package com.notemusicali + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.window.ComposeUIViewController +import com.notemusicali.navigation.AppNavigation +import com.notemusicali.scan.AppSettings +import com.notemusicali.ui.theme.NoteMusicaliTheme +import com.notemusicali.util.CrashHandler + +fun MainViewController() = ComposeUIViewController { + CrashHandler.install() + AppSettings.migrateIfNeeded() + NoteMusicaliTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + AppNavigation() + } + } +} diff --git a/shared/src/iosMain/kotlin/com/notemusicali/audio/AudioSource.ios.kt b/shared/src/iosMain/kotlin/com/notemusicali/audio/AudioSource.ios.kt new file mode 100644 index 0000000..f333058 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/notemusicali/audio/AudioSource.ios.kt @@ -0,0 +1,167 @@ +package com.notemusicali.audio + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.get +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import platform.AVFAudio.AVAudioEngine +import platform.AVFAudio.AVAudioFormat +import platform.AVFAudio.AVAudioPCMBuffer +import platform.AVFAudio.AVAudioSession +import platform.AVFAudio.AVAudioSessionCategoryPlayAndRecord +import platform.AVFAudio.AVAudioSessionModeDefault +import platform.AVFAudio.AVAudioSessionPortOverrideSpeaker +import platform.AVFAudio.setActive +import platform.AVFAudio.setPreferredSampleRate +import platform.Foundation.NSLog +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@OptIn(ExperimentalForeignApi::class) +private class IosAudioSource( + private val sampleRate: Int, + private val bufferSize: Int, +) : AudioSource { + + override fun audioFlow(): Flow = callbackFlow { + try { + NSLog("InTono AudioSource: starting setup sampleRate=%d bufferSize=%d", sampleRate, bufferSize) + + val session = AVAudioSession.sharedInstance() + val categoryOk = session.setCategory( + AVAudioSessionCategoryPlayAndRecord, + mode = AVAudioSessionModeDefault, + options = 8u, // DefaultToSpeaker + error = null, + ) + NSLog("InTono AudioSource: setCategory result=%s", if (categoryOk) "OK" else "FAIL") + + session.setPreferredSampleRate(sampleRate.toDouble(), error = null) + session.setActive(true, error = null) + + // Route audio to main speaker (works even with PlayAndRecord) + session.overrideOutputAudioPort(AVAudioSessionPortOverrideSpeaker, error = null) + + // Request microphone permission at runtime + val granted = suspendCoroutine { cont -> + session.requestRecordPermission { allowed -> + cont.resume(allowed) + } + } + NSLog("InTono AudioSource: mic permission=%s", if (granted) "granted" else "denied") + + if (!granted) { + close() + return@callbackFlow + } + + val engine = AVAudioEngine() + NSLog("InTono AudioSource: engine created") + + val inputNode = engine.inputNode + NSLog("InTono AudioSource: inputNode obtained") + + // Use the input node's native format for the tap to avoid format conflicts. + // Then resample/convert ourselves if needed. + val hwFormat = inputNode.outputFormatForBus(0u) + val hwSampleRate = hwFormat.sampleRate + val hwChannels = hwFormat.channelCount + NSLog("InTono AudioSource: hw format sampleRate=%.0f channels=%d", hwSampleRate, hwChannels) + + // Use mono format at hardware sample rate to avoid resampling issues + val tapFormat = if (hwSampleRate > 0.0) { + AVAudioFormat( + standardFormatWithSampleRate = hwSampleRate, + channels = 1u, + ) + } else { + AVAudioFormat( + standardFormatWithSampleRate = sampleRate.toDouble(), + channels = 1u, + ) + } + + // Calculate downsample ratio if hardware rate differs from requested rate + val actualSampleRate = if (hwSampleRate > 0.0) hwSampleRate else sampleRate.toDouble() + val downsampleRatio = actualSampleRate / sampleRate.toDouble() + + // Accumulation buffer: tap may deliver variable-sized chunks, + // but PitchDetector needs exactly `bufferSize` samples. + val accumulator = FloatArray(bufferSize) + var accumulatorPos = 0 + + val tapBufferSize: UInt = 4096u + + NSLog("InTono AudioSource: installing tap, downsampleRatio=%.4f", downsampleRatio) + + // Tap the input node with hardware-compatible format. + inputNode.installTapOnBus( + bus = 0u, + bufferSize = tapBufferSize, + format = tapFormat, + ) { buffer: AVAudioPCMBuffer?, _ -> + if (buffer == null) return@installTapOnBus + + val frameCount = buffer.frameLength.toInt() + val channelData = buffer.floatChannelData ?: return@installTapOnBus + val samples = channelData[0] ?: return@installTapOnBus + + if (downsampleRatio <= 1.01) { + // No resampling needed (rates are essentially the same) + var srcPos = 0 + while (srcPos < frameCount) { + val remaining = bufferSize - accumulatorPos + val available = frameCount - srcPos + val toCopy = minOf(remaining, available) + + for (i in 0 until toCopy) { + accumulator[accumulatorPos + i] = samples[srcPos + i] + } + accumulatorPos += toCopy + srcPos += toCopy + + if (accumulatorPos == bufferSize) { + trySend(accumulator.copyOf()) + accumulatorPos = 0 + } + } + } else { + // Simple decimation for downsampling (e.g., 48000→44100) + var srcIdx = 0.0 + while (srcIdx < frameCount) { + val idx = srcIdx.toInt() + if (idx < frameCount) { + accumulator[accumulatorPos] = samples[idx] + accumulatorPos++ + if (accumulatorPos == bufferSize) { + trySend(accumulator.copyOf()) + accumulatorPos = 0 + } + } + srcIdx += downsampleRatio + } + } + } + + NSLog("InTono AudioSource: tap installed, preparing engine") + engine.prepare() + + val startOk = engine.startAndReturnError(null) + NSLog("InTono AudioSource: engine start result=%s", if (startOk) "OK" else "FAIL") + + awaitClose { + NSLog("InTono AudioSource: closing, removing tap") + inputNode.removeTapOnBus(0u) + engine.stop() + session.setActive(false, error = null) + } + } catch (e: Exception) { + NSLog("InTono AudioSource: EXCEPTION %s", e.message ?: "unknown") + close() + } + } +} + +actual fun createAudioSource(sampleRate: Int, bufferSize: Int): AudioSource = + IosAudioSource(sampleRate, bufferSize) diff --git a/shared/src/iosMain/kotlin/com/notemusicali/audio/ToneGenerator.ios.kt b/shared/src/iosMain/kotlin/com/notemusicali/audio/ToneGenerator.ios.kt new file mode 100644 index 0000000..ee20b92 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/notemusicali/audio/ToneGenerator.ios.kt @@ -0,0 +1,109 @@ +package com.notemusicali.audio + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.get +import kotlinx.cinterop.set +import platform.AVFAudio.AVAudioEngine +import platform.Foundation.NSNumber +import platform.Foundation.setValue +import platform.AVFAudio.AVAudioFormat +import platform.AVFAudio.AVAudioPCMBuffer +import platform.AVFAudio.AVAudioPlayerNode +import platform.AVFAudio.AVAudioSession +import platform.AVFAudio.AVAudioSessionCategoryPlayAndRecord +import platform.AVFAudio.AVAudioSessionModeDefault +import platform.AVFAudio.setActive + +@OptIn(ExperimentalForeignApi::class) +private class IosToneGenerator( + private val sampleRate: Int, +) : ToneGenerator { + + private var engine: AVAudioEngine? = null + private var playerNode: AVAudioPlayerNode? = null + + private fun ensureEngine(): Pair { + engine?.let { e -> + playerNode?.let { p -> return Pair(e, p) } + } + + val session = AVAudioSession.sharedInstance() + session.setCategory( + AVAudioSessionCategoryPlayAndRecord, + mode = AVAudioSessionModeDefault, + options = 9u, // MixWithOthers (1) + DefaultToSpeaker (8) + error = null, + ) + session.setActive(true, error = null) + + val e = AVAudioEngine() + val p = AVAudioPlayerNode() + e.attachNode(p) + + val format = AVAudioFormat( + standardFormatWithSampleRate = sampleRate.toDouble(), + channels = 1u, + ) + e.connect(p, to = e.mainMixerNode, format = format) + e.prepare() + e.startAndReturnError(null) + p.play() + + engine = e + playerNode = p + return Pair(e, p) + } + + private fun playBuffer(player: AVAudioPlayerNode, floatSamples: FloatArray) { + val numSamples = floatSamples.size + val format = AVAudioFormat( + standardFormatWithSampleRate = sampleRate.toDouble(), + channels = 1u, + ) + val buf = AVAudioPCMBuffer(pCMFormat = format, frameCapacity = numSamples.toUInt()) + ?: return + + buf.setValue( + value = NSNumber(unsignedInt = numSamples.toUInt()), + forKey = "frameLength", + ) + + val channelData = buf.floatChannelData ?: return + val samples = channelData[0] ?: return + for (i in 0 until numSamples) { + samples[i] = floatSamples[i] + } + + player.play() + player.scheduleBuffer(buf, completionHandler = null) + } + + override fun playTone(frequencyHz: Float, durationMs: Long) { + val (_, player) = ensureEngine() + player.stop() + val samples = WaveGenerator.generateToneSamples(frequencyHz, durationMs, sampleRate) + playBuffer(player, samples) + } + + override fun playClick(accent: Boolean) { + val (_, player) = ensureEngine() + player.stop() + val samples = WaveGenerator.generateClickSamples(accent, sampleRate) + playBuffer(player, samples) + } + + override fun stop() { + playerNode?.stop() + playerNode?.play() + } + + override fun release() { + playerNode?.stop() + engine?.stop() + playerNode = null + engine = null + } +} + +actual fun createToneGenerator(sampleRate: Int): ToneGenerator = + IosToneGenerator(sampleRate) diff --git a/shared/src/iosMain/kotlin/com/notemusicali/scan/PlatformImage.ios.kt b/shared/src/iosMain/kotlin/com/notemusicali/scan/PlatformImage.ios.kt new file mode 100644 index 0000000..08967cc --- /dev/null +++ b/shared/src/iosMain/kotlin/com/notemusicali/scan/PlatformImage.ios.kt @@ -0,0 +1,15 @@ +package com.notemusicali.scan + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * On iOS, PlatformImage wraps raw JPEG byte data. + * Full UIImage integration deferred to post-migration. + */ +actual class PlatformImage(val jpegData: ByteArray) + +@OptIn(ExperimentalEncodingApi::class) +actual fun PlatformImage.toBase64Jpeg(maxDim: Int, quality: Int): String { + return Base64.encode(jpegData) +} diff --git a/shared/src/iosMain/kotlin/com/notemusicali/scan/SecureSettings.ios.kt b/shared/src/iosMain/kotlin/com/notemusicali/scan/SecureSettings.ios.kt new file mode 100644 index 0000000..3218715 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/notemusicali/scan/SecureSettings.ios.kt @@ -0,0 +1,12 @@ +package com.notemusicali.scan + +import com.russhwolf.settings.ExperimentalSettingsImplementation +import com.russhwolf.settings.KeychainSettings +import com.russhwolf.settings.Settings + +@OptIn(ExperimentalSettingsImplementation::class) +internal actual fun createSecureSettings(): Settings? = try { + KeychainSettings(service = "com.notemusicali.intono") +} catch (_: Throwable) { + null +} diff --git a/shared/src/iosMain/kotlin/com/notemusicali/ui/components/FilePicker.ios.kt b/shared/src/iosMain/kotlin/com/notemusicali/ui/components/FilePicker.ios.kt new file mode 100644 index 0000000..97d0e03 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/notemusicali/ui/components/FilePicker.ios.kt @@ -0,0 +1,95 @@ +package com.notemusicali.ui.components + +import androidx.compose.runtime.Composable +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.NSURL +import platform.Foundation.dataWithContentsOfURL +import platform.UIKit.UIApplication +import platform.UIKit.UIDocumentPickerDelegateProtocol +import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UIViewController +import platform.UniformTypeIdentifiers.UTType +import platform.UniformTypeIdentifiers.UTTypeXML +import platform.darwin.NSObject +import platform.posix.memcpy +import com.notemusicali.scores.extractMusicXmlFromBytes + +private var retainedFileDelegate: FilePickerDelegate? = null + +@Composable +actual fun rememberFilePickerLauncher( + mimeTypes: List, + onFileContent: (String) -> Unit, +): () -> Unit { + return { + presentFilePicker(onFileContent) + } +} + +private fun presentFilePicker(onFileContent: (String) -> Unit) { + @Suppress("DEPRECATION") + val rootVC = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return + val topVC = topPresentedViewController(rootVC) + + val delegate = FilePickerDelegate(onFileContent) + retainedFileDelegate = delegate + + val contentTypes = listOfNotNull( + UTTypeXML, + UTType.typeWithFilenameExtension("mxl"), + UTType.typeWithFilenameExtension("musicxml"), + ) + + val picker = UIDocumentPickerViewController(forOpeningContentTypes = contentTypes) + picker.allowsMultipleSelection = false + picker.delegate = delegate + + topVC.presentViewController(picker, animated = true, completion = null) +} + +private fun topPresentedViewController(vc: UIViewController): UIViewController { + vc.presentedViewController?.let { return topPresentedViewController(it) } + return vc +} + +private class FilePickerDelegate( + private val onFileContent: (String) -> Unit, +) : NSObject(), UIDocumentPickerDelegateProtocol { + + @OptIn(ExperimentalForeignApi::class) + override fun documentPicker( + controller: UIDocumentPickerViewController, + didPickDocumentsAtURLs: List<*>, + ) { + retainedFileDelegate = null + val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL ?: return + val accessing = url.startAccessingSecurityScopedResource() + try { + val data = NSData.dataWithContentsOfURL(url) ?: return + val bytes = data.toByteArray() + val content = extractMusicXmlFromBytes(bytes) ?: return + onFileContent(content) + } finally { + if (accessing) url.stopAccessingSecurityScopedResource() + } + } + + override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { + retainedFileDelegate = null + } + + @OptIn(ExperimentalForeignApi::class) + private fun NSData.toByteArray(): ByteArray { + val size = length.toInt() + if (size == 0) return ByteArray(0) + val src = this.bytes ?: return ByteArray(0) + return ByteArray(size).apply { + usePinned { pinned -> + memcpy(pinned.addressOf(0), src, this@toByteArray.length) + } + } + } +} diff --git a/shared/src/iosMain/kotlin/com/notemusicali/ui/components/FolderPicker.ios.kt b/shared/src/iosMain/kotlin/com/notemusicali/ui/components/FolderPicker.ios.kt new file mode 100644 index 0000000..0bd43f2 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/notemusicali/ui/components/FolderPicker.ios.kt @@ -0,0 +1,87 @@ +package com.notemusicali.ui.components + +import androidx.compose.runtime.Composable +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCObjectVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import platform.Foundation.NSError +import platform.Foundation.NSURL +import platform.Foundation.base64EncodedStringWithOptions +import platform.UIKit.UIApplication +import platform.UIKit.UIDocumentPickerDelegateProtocol +import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UIViewController +import platform.UniformTypeIdentifiers.UTTypeFolder +import platform.darwin.NSObject + +private var retainedDelegate: FolderPickerDelegate? = null + +@Composable +actual fun rememberFolderPickerLauncher( + onFolderSelected: (uri: String, displayName: String) -> Unit, +): () -> Unit { + return { + presentFolderPicker(onFolderSelected) + } +} + +private fun presentFolderPicker(onFolderSelected: (String, String) -> Unit) { + @Suppress("DEPRECATION") + val rootVC = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return + val topVC = topViewController(rootVC) + + val delegate = FolderPickerDelegate(onFolderSelected) + retainedDelegate = delegate + + val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder)) + picker.allowsMultipleSelection = false + picker.delegate = delegate + + topVC.presentViewController(picker, animated = true, completion = null) +} + +private fun topViewController(vc: UIViewController): UIViewController { + vc.presentedViewController?.let { return topViewController(it) } + return vc +} + +private class FolderPickerDelegate( + private val onFolderSelected: (String, String) -> Unit, +) : NSObject(), UIDocumentPickerDelegateProtocol { + + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + override fun documentPicker( + controller: UIDocumentPickerViewController, + didPickDocumentsAtURLs: List<*>, + ) { + retainedDelegate = null + val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL ?: return + val accessing = url.startAccessingSecurityScopedResource() + try { + val displayName = url.lastPathComponent ?: "Folder" + + memScoped { + val errorPtr = alloc>() + val bookmarkData = url.bookmarkDataWithOptions( + options = 0u, + includingResourceValuesForKeys = null, + relativeToURL = null, + error = errorPtr.ptr, + ) + if (bookmarkData != null) { + val base64 = bookmarkData.base64EncodedStringWithOptions(0u) + onFolderSelected("bookmark:$base64", displayName) + } + } + } finally { + if (accessing) url.stopAccessingSecurityScopedResource() + } + } + + override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { + retainedDelegate = null + } +} diff --git a/shared/src/iosMain/kotlin/com/notemusicali/ui/components/KeepScreenOn.ios.kt b/shared/src/iosMain/kotlin/com/notemusicali/ui/components/KeepScreenOn.ios.kt new file mode 100644 index 0000000..1eb5f32 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/notemusicali/ui/components/KeepScreenOn.ios.kt @@ -0,0 +1,15 @@ +package com.notemusicali.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import platform.UIKit.UIApplication + +@Composable +actual fun KeepScreenOn() { + DisposableEffect(Unit) { + UIApplication.sharedApplication.idleTimerDisabled = true + onDispose { + UIApplication.sharedApplication.idleTimerDisabled = false + } + } +} diff --git a/shared/src/iosMain/kotlin/com/notemusicali/ui/scan/CameraCapture.ios.kt b/shared/src/iosMain/kotlin/com/notemusicali/ui/scan/CameraCapture.ios.kt new file mode 100644 index 0000000..77127eb --- /dev/null +++ b/shared/src/iosMain/kotlin/com/notemusicali/ui/scan/CameraCapture.ios.kt @@ -0,0 +1,153 @@ +package com.notemusicali.ui.scan + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.notemusicali.scan.PlatformImage +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.useContents +import kotlinx.cinterop.usePinned +import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.CGSizeMake +import platform.Foundation.NSData +import platform.UIKit.UIApplication +import platform.UIKit.UIGraphicsBeginImageContextWithOptions +import platform.UIKit.UIGraphicsEndImageContext +import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext +import platform.UIKit.UIImage +import platform.UIKit.UIImageJPEGRepresentation +import platform.UIKit.UIImagePickerController +import platform.UIKit.UIImagePickerControllerDelegateProtocol +import platform.UIKit.UIImagePickerControllerOriginalImage +import platform.UIKit.UIImagePickerControllerSourceType +import platform.UIKit.UINavigationControllerDelegateProtocol +import platform.UIKit.UIViewController +import platform.darwin.NSObject +import platform.posix.memcpy + +// The picker does not retain its delegate; keep a strong reference until dismissal +private var retainedCameraDelegate: CameraPickerDelegate? = null + +@Composable +actual fun CameraCapture( + onImageCaptured: (PlatformImage) -> Unit, + modifier: Modifier, +) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(24.dp), + ) { + Text( + text = "Fotografa lo spartito oppure scegli un'immagine dalla libreria", + fontSize = 16.sp, + color = Color.White.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + ) + Button( + onClick = { presentImagePicker(preferCamera = true, onImageCaptured) }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFFD700), + contentColor = Color(0xFF1A0533), + ), + ) { + Text("Scatta foto") + } + TextButton(onClick = { presentImagePicker(preferCamera = false, onImageCaptured) }) { + Text("Scegli dalla libreria", color = Color.White.copy(alpha = 0.7f)) + } + } + } +} + +private fun presentImagePicker(preferCamera: Boolean, onImageCaptured: (PlatformImage) -> Unit) { + @Suppress("DEPRECATION") + val rootVC = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return + val topVC = topPresentedViewController(rootVC) + + val delegate = CameraPickerDelegate(onImageCaptured) + retainedCameraDelegate = delegate + + val cameraAvailable = UIImagePickerController.isSourceTypeAvailable( + UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypeCamera, + ) + val picker = UIImagePickerController() + picker.sourceType = if (preferCamera && cameraAvailable) { + UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypeCamera + } else { + // Simulator and camera-less devices fall back to the photo library + UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypePhotoLibrary + } + picker.delegate = delegate + + topVC.presentViewController(picker, animated = true, completion = null) +} + +private fun topPresentedViewController(vc: UIViewController): UIViewController { + vc.presentedViewController?.let { return topPresentedViewController(it) } + return vc +} + +private class CameraPickerDelegate( + private val onImageCaptured: (PlatformImage) -> Unit, +) : NSObject(), UIImagePickerControllerDelegateProtocol, UINavigationControllerDelegateProtocol { + + override fun imagePickerController( + picker: UIImagePickerController, + didFinishPickingMediaWithInfo: Map, + ) { + retainedCameraDelegate = null + picker.dismissViewControllerAnimated(true, completion = null) + val image = didFinishPickingMediaWithInfo[UIImagePickerControllerOriginalImage] as? UIImage + ?: return + // The OMR providers resize to ~1024px anyway; cap here to keep the base64 payload small + val jpeg = UIImageJPEGRepresentation(image.scaledDownTo(1600.0), 0.85) ?: return + onImageCaptured(PlatformImage(jpeg.toByteArray())) + } + + override fun imagePickerControllerDidCancel(picker: UIImagePickerController) { + retainedCameraDelegate = null + picker.dismissViewControllerAnimated(true, completion = null) + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun UIImage.scaledDownTo(maxDim: Double): UIImage { + val width = size.useContents { width } + val height = size.useContents { height } + val largest = maxOf(width, height) + if (largest <= maxDim || largest <= 0.0) return this + val scale = maxDim / largest + UIGraphicsBeginImageContextWithOptions(CGSizeMake(width * scale, height * scale), false, 1.0) + drawInRect(CGRectMake(0.0, 0.0, width * scale, height * scale)) + val result = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return result ?: this +} + +@OptIn(ExperimentalForeignApi::class) +private fun NSData.toByteArray(): ByteArray { + val size = length.toInt() + if (size == 0) return ByteArray(0) + val src = bytes ?: return ByteArray(0) + return ByteArray(size).apply { + usePinned { pinned -> + memcpy(pinned.addressOf(0), src, this@toByteArray.length) + } + } +} diff --git a/shared/src/iosMain/kotlin/com/notemusicali/util/CrashHandler.ios.kt b/shared/src/iosMain/kotlin/com/notemusicali/util/CrashHandler.ios.kt new file mode 100644 index 0000000..6a8852d --- /dev/null +++ b/shared/src/iosMain/kotlin/com/notemusicali/util/CrashHandler.ios.kt @@ -0,0 +1,11 @@ +package com.notemusicali.util + +import platform.Foundation.NSLog + +actual object CrashHandler { + actual fun install() { + // On iOS, Kotlin/Native uncaught exceptions terminate the process. + // Log a message to help with post-crash diagnostics. + NSLog("InTono: CrashHandler installed") + } +} diff --git a/shared/src/iosMain/kotlin/com/notemusicali/util/TimeProvider.ios.kt b/shared/src/iosMain/kotlin/com/notemusicali/util/TimeProvider.ios.kt new file mode 100644 index 0000000..6f7288b --- /dev/null +++ b/shared/src/iosMain/kotlin/com/notemusicali/util/TimeProvider.ios.kt @@ -0,0 +1,29 @@ +package com.notemusicali.util + +import platform.Foundation.NSDate +import platform.Foundation.NSDateFormatter +import platform.Foundation.NSLocale +import platform.Foundation.timeIntervalSince1970 + +actual fun currentTimeMillis(): Long = + (NSDate().timeIntervalSince1970 * 1000).toLong() + +actual fun formatEpochDate(epochMillis: Long): String { + val formatter = NSDateFormatter() + formatter.dateFormat = "dd/MM/yyyy HH:mm" + // NSDate reference date is 2001-01-01, Unix epoch is 1970-01-01 + // Difference: 978307200 seconds + val secondsSince1970 = epochMillis / 1000.0 + val secondsSinceRef = secondsSince1970 - 978307200.0 + val date = NSDate(timeIntervalSinceReferenceDate = secondsSinceRef) + return formatter.stringFromDate(date) +} + +actual fun currentDateString(): String { + val formatter = NSDateFormatter() + // Machine-readable format parsed by DailyGoalManager: must stay ASCII (and + // Gregorian) regardless of the device locale/calendar + formatter.locale = NSLocale(localeIdentifier = "en_US_POSIX") + formatter.dateFormat = "yyyyMMdd" + return formatter.stringFromDate(NSDate()) +} diff --git a/video/.agents/skills/remotion-best-practices/SKILL.md b/video/.agents/skills/remotion-best-practices/SKILL.md new file mode 100644 index 0000000..7ddaa0f --- /dev/null +++ b/video/.agents/skills/remotion-best-practices/SKILL.md @@ -0,0 +1,61 @@ +--- +name: remotion-best-practices +description: Best practices for Remotion - Video creation in React +metadata: + tags: remotion, video, react, animation, composition +--- + +## When to use + +Use this skills whenever you are dealing with Remotion code to obtain the domain-specific knowledge. + +## Captions + +When dealing with captions or subtitles, load the [./rules/subtitles.md](./rules/subtitles.md) file for more information. + +## Using FFmpeg + +For some video operations, such as trimming videos or detecting silence, FFmpeg should be used. Load the [./rules/ffmpeg.md](./rules/ffmpeg.md) file for more information. + +## Audio visualization + +When needing to visualize audio (spectrum bars, waveforms, bass-reactive effects), load the [./rules/audio-visualization.md](./rules/audio-visualization.md) file for more information. + +## Sound effects + +When needing to use sound effects, load the [./rules/sound-effects.md](./rules/sound-effects.md) file for more information. + +## How to use + +Read individual rule files for detailed explanations and code examples: + +- [rules/3d.md](rules/3d.md) - 3D content in Remotion using Three.js and React Three Fiber +- [rules/animations.md](rules/animations.md) - Fundamental animation skills for Remotion +- [rules/assets.md](rules/assets.md) - Importing images, videos, audio, and fonts into Remotion +- [rules/audio.md](rules/audio.md) - Using audio and sound in Remotion - importing, trimming, volume, speed, pitch +- [rules/calculate-metadata.md](rules/calculate-metadata.md) - Dynamically set composition duration, dimensions, and props +- [rules/can-decode.md](rules/can-decode.md) - Check if a video can be decoded by the browser using Mediabunny +- [rules/charts.md](rules/charts.md) - Chart and data visualization patterns for Remotion (bar, pie, line, stock charts) +- [rules/compositions.md](rules/compositions.md) - Defining compositions, stills, folders, default props and dynamic metadata +- [rules/extract-frames.md](rules/extract-frames.md) - Extract frames from videos at specific timestamps using Mediabunny +- [rules/fonts.md](rules/fonts.md) - Loading Google Fonts and local fonts in Remotion +- [rules/get-audio-duration.md](rules/get-audio-duration.md) - Getting the duration of an audio file in seconds with Mediabunny +- [rules/get-video-dimensions.md](rules/get-video-dimensions.md) - Getting the width and height of a video file with Mediabunny +- [rules/get-video-duration.md](rules/get-video-duration.md) - Getting the duration of a video file in seconds with Mediabunny +- [rules/gifs.md](rules/gifs.md) - Displaying GIFs synchronized with Remotion's timeline +- [rules/images.md](rules/images.md) - Embedding images in Remotion using the Img component +- [rules/light-leaks.md](rules/light-leaks.md) - Light leak overlay effects using @remotion/light-leaks +- [rules/lottie.md](rules/lottie.md) - Embedding Lottie animations in Remotion +- [rules/measuring-dom-nodes.md](rules/measuring-dom-nodes.md) - Measuring DOM element dimensions in Remotion +- [rules/measuring-text.md](rules/measuring-text.md) - Measuring text dimensions, fitting text to containers, and checking overflow +- [rules/sequencing.md](rules/sequencing.md) - Sequencing patterns for Remotion - delay, trim, limit duration of items +- [rules/tailwind.md](rules/tailwind.md) - Using TailwindCSS in Remotion +- [rules/text-animations.md](rules/text-animations.md) - Typography and text animation patterns for Remotion +- [rules/timing.md](rules/timing.md) - Interpolation curves in Remotion - linear, easing, spring animations +- [rules/transitions.md](rules/transitions.md) - Scene transition patterns for Remotion +- [rules/transparent-videos.md](rules/transparent-videos.md) - Rendering out a video with transparency +- [rules/trimming.md](rules/trimming.md) - Trimming patterns for Remotion - cut the beginning or end of animations +- [rules/videos.md](rules/videos.md) - Embedding videos in Remotion - trimming, volume, speed, looping, pitch +- [rules/parameters.md](rules/parameters.md) - Make a video parametrizable by adding a Zod schema +- [rules/maps.md](rules/maps.md) - Add a map using Mapbox and animate it +- [rules/voiceover.md](rules/voiceover.md) - Adding AI-generated voiceover to Remotion compositions using ElevenLabs TTS diff --git a/video/.agents/skills/remotion-best-practices/rules/3d.md b/video/.agents/skills/remotion-best-practices/rules/3d.md new file mode 100644 index 0000000..c9d254f --- /dev/null +++ b/video/.agents/skills/remotion-best-practices/rules/3d.md @@ -0,0 +1,86 @@ +--- +name: 3d +description: 3D content in Remotion using Three.js and React Three Fiber. +metadata: + tags: 3d, three, threejs +--- + +# Using Three.js and React Three Fiber in Remotion + +Follow React Three Fiber and Three.js best practices. +Only the following Remotion-specific rules need to be followed: + +## Prerequisites + +First, the `@remotion/three` package needs to be installed. +If it is not, use the following command: + +```bash +npx remotion add @remotion/three # If project uses npm +bunx remotion add @remotion/three # If project uses bun +yarn remotion add @remotion/three # If project uses yarn +pnpm exec remotion add @remotion/three # If project uses pnpm +``` + +## Using ThreeCanvas + +You MUST wrap 3D content in `` and include proper lighting. +`` MUST have a `width` and `height` prop. + +```tsx +import { ThreeCanvas } from "@remotion/three"; +import { useVideoConfig } from "remotion"; + +const { width, height } = useVideoConfig(); + + + + + + + + +; +``` + +## No animations not driven by `useCurrentFrame()` + +Shaders, models etc MUST NOT animate by themselves. +No animations are allowed unless they are driven by `useCurrentFrame()`. +Otherwise, it will cause flickering during rendering. + +Using `useFrame()` from `@react-three/fiber` is forbidden. + +## Animate using `useCurrentFrame()` + +Use `useCurrentFrame()` to perform animations. + +```tsx +const frame = useCurrentFrame(); +const rotationY = frame * 0.02; + + + + +; +``` + +## Using `` inside `` + +The `layout` prop of any `` inside a `` must be set to `none`. + +```tsx +import { Sequence } from "remotion"; +import { ThreeCanvas } from "@remotion/three"; + +const { width, height } = useVideoConfig(); + + + + + + + + +; +``` diff --git a/video/.agents/skills/remotion-best-practices/rules/animations.md b/video/.agents/skills/remotion-best-practices/rules/animations.md new file mode 100644 index 0000000..619591e --- /dev/null +++ b/video/.agents/skills/remotion-best-practices/rules/animations.md @@ -0,0 +1,27 @@ +--- +name: animations +description: Fundamental animation skills for Remotion +metadata: + tags: animations, transitions, frames, useCurrentFrame +--- + +All animations MUST be driven by the `useCurrentFrame()` hook. +Write animations in seconds and multiply them by the `fps` value from `useVideoConfig()`. + +```tsx +import { useCurrentFrame } from "remotion"; + +export const FadeIn = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + + const opacity = interpolate(frame, [0, 2 * fps], [0, 1], { + extrapolateRight: "clamp", + }); + + return
Hello World!
; +}; +``` + +CSS transitions or animations are FORBIDDEN - they will not render correctly. +Tailwind animation class names are FORBIDDEN - they will not render correctly. diff --git a/video/.agents/skills/remotion-best-practices/rules/assets.md b/video/.agents/skills/remotion-best-practices/rules/assets.md new file mode 100644 index 0000000..a5d4015 --- /dev/null +++ b/video/.agents/skills/remotion-best-practices/rules/assets.md @@ -0,0 +1,78 @@ +--- +name: assets +description: Importing images, videos, audio, and fonts into Remotion +metadata: + tags: assets, staticFile, images, fonts, public +--- + +# Importing assets in Remotion + +## The public folder + +Place assets in the `public/` folder at your project root. + +## Using staticFile() + +You MUST use `staticFile()` to reference files from the `public/` folder: + +```tsx +import { Img, staticFile } from "remotion"; + +export const MyComposition = () => { + return ; +}; +``` + +The function returns an encoded URL that works correctly when deploying to subdirectories. + +## Using with components + +**Images:** + +```tsx +import { Img, staticFile } from "remotion"; + +; +``` + +**Videos:** + +```tsx +import { Video } from "@remotion/media"; +import { staticFile } from "remotion"; + +