From 21ad969b100f0026ee073ee2538f702ad4dc9bb3 Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 22 Jul 2026 10:06:59 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20InTono=20=E2=80=94=20app=20?= =?UTF-8?q?KMP=20per=20l'apprendimento=20musicale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/scheduled_tasks.lock | 1 + .gitignore | 31 + Apri con claude.command | 3 + GUIDA_UTENTE.md | 162 + InTono_Guida_Utente.pdf | Bin 0 -> 144736 bytes PRIVACY_POLICY.html | 67 + README.md | 100 + app/build.gradle.kts | 70 + app/proguard-rules.pro | 64 + app/src/main/AndroidManifest.xml | 26 + .../java/com/notemusicali/MainActivity.kt | 61 + .../res/drawable/ic_launcher_background.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 29 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4215 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 4215 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2262 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2262 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 6497 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 6497 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 12321 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 12321 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 18425 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 18425 bytes app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 4 + appstore-description.html | 492 ++ build.gradle.kts | 9 + desktopApp/build.gradle.kts | 52 + .../kotlin/com/notemusicali/Main.kt | 29 + .../plans/2026-07-22-staff-rendering.md | 577 ++ .../2026-07-22-staff-rendering-design.md | 71 + gradle.properties | 4 + gradle/libs.versions.toml | 59 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 53636 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 160 + gradlew.bat | 90 + .../AppIcon.appiconset/Contents.json | 116 + .../AppIcon.appiconset/icon_1024.png | Bin 0 -> 124265 bytes .../AppIcon.appiconset/icon_120.png | Bin 0 -> 9352 bytes .../AppIcon.appiconset/icon_152.png | Bin 0 -> 13502 bytes .../AppIcon.appiconset/icon_167.png | Bin 0 -> 14982 bytes .../AppIcon.appiconset/icon_180.png | Bin 0 -> 16816 bytes .../AppIcon.appiconset/icon_20.png | Bin 0 -> 659 bytes .../AppIcon.appiconset/icon_29.png | Bin 0 -> 1076 bytes .../AppIcon.appiconset/icon_40.png | Bin 0 -> 1673 bytes .../AppIcon.appiconset/icon_58.png | Bin 0 -> 3075 bytes .../AppIcon.appiconset/icon_60.png | Bin 0 -> 3308 bytes .../AppIcon.appiconset/icon_76.png | Bin 0 -> 4464 bytes .../AppIcon.appiconset/icon_80.png | Bin 0 -> 4857 bytes .../AppIcon.appiconset/icon_87.png | Bin 0 -> 5575 bytes iosApp/iosApp/Assets.xcassets/Contents.json | 6 + .../LaunchBackground.colorset/Contents.json | 20 + .../LaunchIcon.imageset/Contents.json | 23 + .../LaunchIcon.imageset/icon_1x.png | Bin 0 -> 230 bytes .../LaunchIcon.imageset/icon_2x.png | Bin 0 -> 415 bytes .../LaunchIcon.imageset/icon_3x.png | Bin 0 -> 669 bytes iosApp/iosApp/Info.plist | 56 + iosApp/iosApp/PrivacyInfo.xcprivacy | 48 + iosApp/iosApp/iOSApp.swift | 19 + iosApp/project.yml | 41 + privacy-policy.html | 79 + settings.gradle.kts | 26 + shared/build.gradle.kts | 103 + .../notemusicali/audio/AudioSource.android.kt | 63 + .../audio/ToneGenerator.android.kt | 69 + .../scan/PlatformImage.android.kt | 25 + .../scan/SecureSettings.android.kt | 27 + .../ui/components/FilePicker.android.kt | 35 + .../ui/components/FolderPicker.android.kt | 33 + .../ui/components/KeepScreenOn.android.kt | 18 + .../ui/scan/CameraCapture.android.kt | 126 + .../notemusicali/util/CrashHandler.android.kt | 15 + .../notemusicali/util/TimeProvider.android.kt | 18 + .../composeResources/values-en/strings.xml | 237 + .../composeResources/values/strings.xml | 237 + .../com/notemusicali/audio/AudioProcessor.kt | 86 + .../com/notemusicali/audio/AudioSource.kt | 15 + .../notemusicali/audio/InstrumentPreset.kt | 77 + .../com/notemusicali/audio/NoteConverter.kt | 55 + .../com/notemusicali/audio/PitchDetector.kt | 150 + .../com/notemusicali/audio/ToneGenerator.kt | 24 + .../com/notemusicali/audio/WaveGenerator.kt | 131 + .../notemusicali/challenge/ChallengeEngine.kt | 70 + .../challenge/ChallengeRepository.kt | 29 + .../eartraining/EarTrainingEngine.kt | 111 + .../eartraining/EarTrainingStats.kt | 42 + .../exercises/ExerciseRepository.kt | 129 + .../notemusicali/goals/DailyGoalManager.kt | 148 + .../notemusicali/history/PracticeSession.kt | 35 + .../notemusicali/history/SessionRepository.kt | 30 + .../notemusicali/metronome/MetronomeEngine.kt | 72 + .../com/notemusicali/music/ItalianNotation.kt | 65 + .../com/notemusicali/music/MusicXmlParser.kt | 106 + .../com/notemusicali/music/MusicalNote.kt | 76 + .../com/notemusicali/music/NoteSequence.kt | 21 + .../com/notemusicali/music/ScaleGenerator.kt | 76 + .../notemusicali/navigation/AppNavigation.kt | 267 + .../com/notemusicali/scan/ApiKeyStore.kt | 76 + .../com/notemusicali/scan/AppSettings.kt | 27 + .../notemusicali/scan/ClaudeOmrProcessor.kt | 123 + .../com/notemusicali/scan/NoteParser.kt | 32 + .../kotlin/com/notemusicali/scan/OmrHttp.kt | 32 + .../com/notemusicali/scan/OmrProcessor.kt | 10 + .../notemusicali/scan/OpenAiOmrProcessor.kt | 113 + .../com/notemusicali/scan/PlatformImage.kt | 11 + .../com/notemusicali/stats/StatsCalculator.kt | 121 + .../ui/challenge/ChallengeScreen.kt | 292 + .../ui/challenge/ChallengeSetupScreen.kt | 164 + .../ui/challenge/ChallengeViewModel.kt | 220 + .../ui/challenge/LeaderboardScreen.kt | 120 + .../notemusicali/ui/components/BackTopBar.kt | 41 + .../notemusicali/ui/components/FilePicker.kt | 14 + .../ui/components/FolderPicker.kt | 8 + .../ui/components/GradientCard.kt | 56 + .../ui/components/KeepScreenOn.kt | 6 + .../ui/components/StaffContextView.kt | 170 + .../ui/components/StaffFullView.kt | 205 + .../notemusicali/ui/components/StaffLayout.kt | 171 + .../ui/components/StaffRenderer.kt | 199 + .../notemusicali/ui/components/StaffView.kt | 214 + .../ui/eartraining/EarTrainingScreen.kt | 316 + .../ui/eartraining/EarTrainingViewModel.kt | 141 + .../ui/exercises/ExercisesScreen.kt | 135 + .../notemusicali/ui/goals/DailyGoalScreen.kt | 209 + .../com/notemusicali/ui/guide/GuideScreen.kt | 287 + .../notemusicali/ui/history/HistoryScreen.kt | 349 + .../com/notemusicali/ui/home/HomeScreen.kt | 344 + .../ui/metronome/MetronomeScreen.kt | 369 + .../ui/metronome/MetronomeViewModel.kt | 155 + .../ui/practice/ManualInputScreen.kt | 496 ++ .../ui/practice/PracticeScreen.kt | 513 ++ .../ui/practice/PracticeSetupScreen.kt | 155 + .../ui/practice/PracticeViewModel.kt | 231 + .../ui/reference/ReferenceNoteScreen.kt | 206 + .../ui/scales/ScaleGeneratorScreen.kt | 247 + .../com/notemusicali/ui/scan/AiProvider.kt | 6 + .../com/notemusicali/ui/scan/CameraCapture.kt | 15 + .../com/notemusicali/ui/scan/ScanScreen.kt | 427 + .../com/notemusicali/ui/scan/ScanViewModel.kt | 122 + .../com/notemusicali/ui/stats/LineChart.kt | 117 + .../com/notemusicali/ui/stats/StatsScreen.kt | 179 + .../kotlin/com/notemusicali/ui/theme/Theme.kt | 107 + .../com/notemusicali/ui/tuner/TunerScreen.kt | 283 + .../notemusicali/ui/tuner/TunerViewModel.kt | 104 + .../com/notemusicali/util/CrashHandler.kt | 8 + .../com/notemusicali/util/TimeProvider.kt | 8 + .../notemusicali/audio/NoteConverterTest.kt | 75 + .../notemusicali/audio/PitchDetectorTest.kt | 94 + .../com/notemusicali/audio/PitchResultTest.kt | 23 + .../challenge/ChallengeScoringTest.kt | 27 + .../metronome/MetronomeEngineTest.kt | 64 + .../notemusicali/music/FlatNotationTest.kt | 68 + .../notemusicali/music/ItalianNotationTest.kt | 112 + .../notemusicali/music/MusicXmlParserTest.kt | 288 + .../com/notemusicali/scan/ApiKeyStoreTest.kt | 61 + .../scan/OmrResponseParsingTest.kt | 62 + .../ui/components/StaffGeometryTest.kt | 123 + .../ui/components/StaffLayoutTest.kt | 56 + .../notemusicali/audio/AudioSource.desktop.kt | 62 + .../audio/ToneGenerator.desktop.kt | 67 + .../scan/PlatformImage.desktop.kt | 26 + .../scan/SecureSettings.desktop.kt | 6 + .../ui/components/FilePicker.desktop.kt | 37 + .../ui/components/FolderPicker.desktop.kt | 20 + .../ui/components/KeepScreenOn.desktop.kt | 8 + .../ui/scan/CameraCapture.desktop.kt | 76 + .../notemusicali/util/CrashHandler.desktop.kt | 10 + .../notemusicali/util/TimeProvider.desktop.kt | 18 + .../util/TimeProviderLocaleTest.kt | 24 + .../com/notemusicali/MainViewController.kt | 24 + .../com/notemusicali/audio/AudioSource.ios.kt | 167 + .../notemusicali/audio/ToneGenerator.ios.kt | 109 + .../notemusicali/scan/PlatformImage.ios.kt | 15 + .../notemusicali/scan/SecureSettings.ios.kt | 12 + .../ui/components/FilePicker.ios.kt | 95 + .../ui/components/FolderPicker.ios.kt | 87 + .../ui/components/KeepScreenOn.ios.kt | 15 + .../notemusicali/ui/scan/CameraCapture.ios.kt | 153 + .../com/notemusicali/util/CrashHandler.ios.kt | 11 + .../com/notemusicali/util/TimeProvider.ios.kt | 29 + .../skills/remotion-best-practices/SKILL.md | 61 + .../remotion-best-practices/rules/3d.md | 86 + .../rules/animations.md | 27 + .../remotion-best-practices/rules/assets.md | 78 + .../rules/assets/charts-bar-chart.tsx | 173 + .../assets/text-animations-typewriter.tsx | 100 + .../assets/text-animations-word-highlight.tsx | 103 + .../rules/audio-visualization.md | 198 + .../remotion-best-practices/rules/audio.md | 169 + .../rules/calculate-metadata.md | 134 + .../rules/can-decode.md | 75 + .../remotion-best-practices/rules/charts.md | 120 + .../rules/compositions.md | 154 + .../rules/display-captions.md | 184 + .../rules/extract-frames.md | 229 + .../remotion-best-practices/rules/ffmpeg.md | 38 + .../remotion-best-practices/rules/fonts.md | 152 + .../rules/get-audio-duration.md | 58 + .../rules/get-video-dimensions.md | 68 + .../rules/get-video-duration.md | 60 + .../remotion-best-practices/rules/gifs.md | 141 + .../remotion-best-practices/rules/images.md | 134 + .../rules/import-srt-captions.md | 69 + .../rules/light-leaks.md | 73 + .../remotion-best-practices/rules/lottie.md | 70 + .../remotion-best-practices/rules/maps.md | 412 + .../rules/measuring-dom-nodes.md | 34 + .../rules/measuring-text.md | 140 + .../rules/parameters.md | 109 + .../rules/sequencing.md | 118 + .../remotion-best-practices/rules/sfx.md | 26 + .../rules/subtitles.md | 36 + .../remotion-best-practices/rules/tailwind.md | 11 + .../rules/text-animations.md | 20 + .../remotion-best-practices/rules/timing.md | 179 + .../rules/transcribe-captions.md | 70 + .../rules/transitions.md | 197 + .../rules/transparent-videos.md | 106 + .../remotion-best-practices/rules/trimming.md | 51 + .../remotion-best-practices/rules/videos.md | 171 + .../rules/voiceover.md | 99 + video/.claude/skills/remotion-best-practices | 1 + video/.gemini/skills/remotion-best-practices | 1 + video/.gitignore | 7 + video/.prettierrc | 5 + video/README.md | 54 + video/eslint.config.mjs | 3 + video/generate-music.mjs | 213 + video/package-lock.json | 7038 +++++++++++++++++ video/package.json | 33 + video/postcss.config.mjs | 5 + video/public/music.wav | Bin 0 -> 5292044 bytes video/remotion.config.ts | 13 + video/src/Composition.tsx | 130 + video/src/Root.tsx | 19 + video/src/index.css | 1 + video/src/index.ts | 4 + video/src/lib/constants.ts | 84 + video/src/scenes/FeaturesScene.tsx | 338 + video/src/scenes/IntroScene.tsx | 277 + video/src/scenes/OutroScene.tsx | 289 + video/src/scenes/StaffScene.tsx | 380 + video/tsconfig.json | 15 + 245 files changed, 28963 insertions(+) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 .gitignore create mode 100755 Apri con claude.command create mode 100644 GUIDA_UTENTE.md create mode 100644 InTono_Guida_Utente.pdf create mode 100644 PRIVACY_POLICY.html create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/notemusicali/MainActivity.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 appstore-description.html create mode 100644 build.gradle.kts create mode 100644 desktopApp/build.gradle.kts create mode 100644 desktopApp/src/desktopMain/kotlin/com/notemusicali/Main.kt create mode 100644 docs/superpowers/plans/2026-07-22-staff-rendering.md create mode 100644 docs/superpowers/specs/2026-07-22-staff-rendering-design.md create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon_1024.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon_120.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon_152.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon_167.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon_180.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon_20.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon_29.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon_40.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon_58.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon_60.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon_76.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon_80.png create mode 100644 iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon_87.png create mode 100644 iosApp/iosApp/Assets.xcassets/Contents.json create mode 100644 iosApp/iosApp/Assets.xcassets/LaunchBackground.colorset/Contents.json create mode 100644 iosApp/iosApp/Assets.xcassets/LaunchIcon.imageset/Contents.json create mode 100644 iosApp/iosApp/Assets.xcassets/LaunchIcon.imageset/icon_1x.png create mode 100644 iosApp/iosApp/Assets.xcassets/LaunchIcon.imageset/icon_2x.png create mode 100644 iosApp/iosApp/Assets.xcassets/LaunchIcon.imageset/icon_3x.png create mode 100644 iosApp/iosApp/Info.plist create mode 100644 iosApp/iosApp/PrivacyInfo.xcprivacy create mode 100644 iosApp/iosApp/iOSApp.swift create mode 100644 iosApp/project.yml create mode 100644 privacy-policy.html create mode 100644 settings.gradle.kts create mode 100644 shared/build.gradle.kts create mode 100644 shared/src/androidMain/kotlin/com/notemusicali/audio/AudioSource.android.kt create mode 100644 shared/src/androidMain/kotlin/com/notemusicali/audio/ToneGenerator.android.kt create mode 100644 shared/src/androidMain/kotlin/com/notemusicali/scan/PlatformImage.android.kt create mode 100644 shared/src/androidMain/kotlin/com/notemusicali/scan/SecureSettings.android.kt create mode 100644 shared/src/androidMain/kotlin/com/notemusicali/ui/components/FilePicker.android.kt create mode 100644 shared/src/androidMain/kotlin/com/notemusicali/ui/components/FolderPicker.android.kt create mode 100644 shared/src/androidMain/kotlin/com/notemusicali/ui/components/KeepScreenOn.android.kt create mode 100644 shared/src/androidMain/kotlin/com/notemusicali/ui/scan/CameraCapture.android.kt create mode 100644 shared/src/androidMain/kotlin/com/notemusicali/util/CrashHandler.android.kt create mode 100644 shared/src/androidMain/kotlin/com/notemusicali/util/TimeProvider.android.kt create mode 100644 shared/src/commonMain/composeResources/values-en/strings.xml create mode 100644 shared/src/commonMain/composeResources/values/strings.xml create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/audio/AudioProcessor.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/audio/AudioSource.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/audio/InstrumentPreset.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/audio/NoteConverter.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/audio/PitchDetector.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/audio/ToneGenerator.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/audio/WaveGenerator.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/challenge/ChallengeEngine.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/challenge/ChallengeRepository.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/eartraining/EarTrainingEngine.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/eartraining/EarTrainingStats.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/exercises/ExerciseRepository.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/goals/DailyGoalManager.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/history/PracticeSession.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/history/SessionRepository.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/metronome/MetronomeEngine.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/music/ItalianNotation.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/music/MusicXmlParser.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/music/MusicalNote.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/music/NoteSequence.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/music/ScaleGenerator.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/navigation/AppNavigation.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/scan/ApiKeyStore.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/scan/AppSettings.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/scan/ClaudeOmrProcessor.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/scan/NoteParser.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/scan/OmrHttp.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/scan/OmrProcessor.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/scan/OpenAiOmrProcessor.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/scan/PlatformImage.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/stats/StatsCalculator.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/ChallengeScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/ChallengeSetupScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/ChallengeViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/challenge/LeaderboardScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/components/BackTopBar.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/components/FilePicker.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/components/FolderPicker.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/components/GradientCard.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/components/KeepScreenOn.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffContextView.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffFullView.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffLayout.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffRenderer.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffView.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/eartraining/EarTrainingScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/eartraining/EarTrainingViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/exercises/ExercisesScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/goals/DailyGoalScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/guide/GuideScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/history/HistoryScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/home/HomeScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/metronome/MetronomeScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/metronome/MetronomeViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/practice/ManualInputScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeSetupScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/reference/ReferenceNoteScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/scales/ScaleGeneratorScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/scan/AiProvider.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/scan/CameraCapture.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/scan/ScanScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/scan/ScanViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/stats/LineChart.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/stats/StatsScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/theme/Theme.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/tuner/TunerScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/ui/tuner/TunerViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/util/CrashHandler.kt create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/util/TimeProvider.kt create mode 100644 shared/src/commonTest/kotlin/com/notemusicali/audio/NoteConverterTest.kt create mode 100644 shared/src/commonTest/kotlin/com/notemusicali/audio/PitchDetectorTest.kt create mode 100644 shared/src/commonTest/kotlin/com/notemusicali/audio/PitchResultTest.kt create mode 100644 shared/src/commonTest/kotlin/com/notemusicali/challenge/ChallengeScoringTest.kt create mode 100644 shared/src/commonTest/kotlin/com/notemusicali/metronome/MetronomeEngineTest.kt create mode 100644 shared/src/commonTest/kotlin/com/notemusicali/music/FlatNotationTest.kt create mode 100644 shared/src/commonTest/kotlin/com/notemusicali/music/ItalianNotationTest.kt create mode 100644 shared/src/commonTest/kotlin/com/notemusicali/music/MusicXmlParserTest.kt create mode 100644 shared/src/commonTest/kotlin/com/notemusicali/scan/ApiKeyStoreTest.kt create mode 100644 shared/src/commonTest/kotlin/com/notemusicali/scan/OmrResponseParsingTest.kt create mode 100644 shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffGeometryTest.kt create mode 100644 shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffLayoutTest.kt create mode 100644 shared/src/desktopMain/kotlin/com/notemusicali/audio/AudioSource.desktop.kt create mode 100644 shared/src/desktopMain/kotlin/com/notemusicali/audio/ToneGenerator.desktop.kt create mode 100644 shared/src/desktopMain/kotlin/com/notemusicali/scan/PlatformImage.desktop.kt create mode 100644 shared/src/desktopMain/kotlin/com/notemusicali/scan/SecureSettings.desktop.kt create mode 100644 shared/src/desktopMain/kotlin/com/notemusicali/ui/components/FilePicker.desktop.kt create mode 100644 shared/src/desktopMain/kotlin/com/notemusicali/ui/components/FolderPicker.desktop.kt create mode 100644 shared/src/desktopMain/kotlin/com/notemusicali/ui/components/KeepScreenOn.desktop.kt create mode 100644 shared/src/desktopMain/kotlin/com/notemusicali/ui/scan/CameraCapture.desktop.kt create mode 100644 shared/src/desktopMain/kotlin/com/notemusicali/util/CrashHandler.desktop.kt create mode 100644 shared/src/desktopMain/kotlin/com/notemusicali/util/TimeProvider.desktop.kt create mode 100644 shared/src/desktopTest/kotlin/com/notemusicali/util/TimeProviderLocaleTest.kt create mode 100644 shared/src/iosMain/kotlin/com/notemusicali/MainViewController.kt create mode 100644 shared/src/iosMain/kotlin/com/notemusicali/audio/AudioSource.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/notemusicali/audio/ToneGenerator.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/notemusicali/scan/PlatformImage.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/notemusicali/scan/SecureSettings.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/notemusicali/ui/components/FilePicker.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/notemusicali/ui/components/FolderPicker.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/notemusicali/ui/components/KeepScreenOn.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/notemusicali/ui/scan/CameraCapture.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/notemusicali/util/CrashHandler.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/notemusicali/util/TimeProvider.ios.kt create mode 100644 video/.agents/skills/remotion-best-practices/SKILL.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/3d.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/animations.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/assets.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/assets/charts-bar-chart.tsx create mode 100644 video/.agents/skills/remotion-best-practices/rules/assets/text-animations-typewriter.tsx create mode 100644 video/.agents/skills/remotion-best-practices/rules/assets/text-animations-word-highlight.tsx create mode 100644 video/.agents/skills/remotion-best-practices/rules/audio-visualization.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/audio.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/calculate-metadata.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/can-decode.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/charts.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/compositions.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/display-captions.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/extract-frames.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/ffmpeg.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/fonts.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/get-audio-duration.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/get-video-dimensions.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/get-video-duration.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/gifs.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/images.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/import-srt-captions.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/light-leaks.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/lottie.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/maps.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/measuring-dom-nodes.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/measuring-text.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/parameters.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/sequencing.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/sfx.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/subtitles.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/tailwind.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/text-animations.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/timing.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/transcribe-captions.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/transitions.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/transparent-videos.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/trimming.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/videos.md create mode 100644 video/.agents/skills/remotion-best-practices/rules/voiceover.md create mode 120000 video/.claude/skills/remotion-best-practices create mode 120000 video/.gemini/skills/remotion-best-practices create mode 100644 video/.gitignore create mode 100644 video/.prettierrc create mode 100644 video/README.md create mode 100644 video/eslint.config.mjs create mode 100644 video/generate-music.mjs create mode 100644 video/package-lock.json create mode 100644 video/package.json create mode 100644 video/postcss.config.mjs create mode 100644 video/public/music.wav create mode 100644 video/remotion.config.ts create mode 100644 video/src/Composition.tsx create mode 100644 video/src/Root.tsx create mode 100644 video/src/index.css create mode 100644 video/src/index.ts create mode 100644 video/src/lib/constants.ts create mode 100644 video/src/scenes/FeaturesScene.tsx create mode 100644 video/src/scenes/IntroScene.tsx create mode 100644 video/src/scenes/OutroScene.tsx create mode 100644 video/src/scenes/StaffScene.tsx create mode 100644 video/tsconfig.json 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 0000000000000000000000000000000000000000..33c009e561fec5f491e00e1953962517c1d94db2 GIT binary patch literal 144736 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 z zV4A{e^KqwVu+MvN97nO2kN0{ z&IP`~3vGOZ6Iozf|AuJ^*#-BJ+XBM_4O>i<2%3$$4EQr9!-H;62y4-25SE^BQ0B7$ zSe5|uIPE<2-iAxd7oNAw=MVQ_XUg&OQNeK@;RlEk? zd9D^&NwzD$GrPa0cAB!AscctV&p);^;T;!Piz^dczPIL}?t9;Q8PU zzNui&M=Tf|jiE4rh-3i%kud=O9*Ey48vj{#6Y^&Tzo-itB_M?jw8tL1ovh~OJt3v@JAgF<}7#;L1kBDv5LSm~^swqf6HeH{ifjkZ< z4OS!!O?1_*$UWldGEiEa(XTRwMEc?-cU2&9m&F6K1GAbn%0&7x$kR3AM@~*W^cY#q zoQlEJx_Wj5)d4}IdE(vjZ?{`3GYZ;LeyMkPMz0zfq2a=~@@_8Sw>FL?He6(%<5rPX zJ+!C$r~DZCxb zh{-ymR{?>>IJcriib-F1NP9)&zDA6avKW$}r+ghvGmsk=kuY10{`&1XO<_a~vFNMi zJ)7yvKDtAz-UBbHmq`5u(|uKCYj0O%mbI>N%{PFmM9N6_o)qB2`3U3AW}v(NN^>$U zb|DJ`F-OCFqAgJKEvsobd%+w%C{>&mPGElWaaunwlQj#6pS-r0YhjBuN)a#Uwo_OZ)d|CxO;OgX-R zJ+?u7oFnTV>S^rjDq=p2k=}FY_&ngmJN^=G#yXCksW?HFaz~bYJI1DJ0WZzaS~NRe zsl_U@J2DOn4nWT3K}Bn#Bw4KL14>rMJK?8M8V|rzI*yv%vX%|vx&ARhsc_bGmNNrW z2FsBXXGV=>n6GZM)#Uut1z8AU+IT}($9kySg#JL+WH%>oY9nX^ z6G<%fa8g{6wF%HtI0a^KkX>B{bl8a_7K=7>Bbe@19BV&xd=<8#Zb)R8bCmXuq}zm z#7#R5?jH<<|1A<@vo~F9pDwpF5GFHBYLEf zxUefb2Ql5TrLeGBtDxqfa`B&b0<=i;s!KHqbrl;-b18-M{bGKReQ`bWt zUutkOXwSC$>b2{gG%?d%~en*3>~6HZDB2SB@~hT+?l zpbc?wZ-J^8Ib==b!Oi3Tsej47q6_hwkl32`>vHybgg#bu*y{rUa1vBo@aCWP;Hfgu#pE4ACX*%wt zS%bzck7YQOQ9DL1(eBwbL(DT7za z^MB>-xEFojz>iH}{|S7jl?0%yW;2R9_Xrm?Iz#dZXM;2rq1T1U#x%D)A&*@TnE7I> zg1PS8(O%*BOsqQ%HVn`d=?HNV7r`JggFyufL|6tE9ZUk}fD0fX1=pKZO^+v>Q2v-f zc1&u_!XZU?U!Hw`i_jWVc|4s(V23Ewe2sR0b=pl^CvNB=XH(ot56kuFh+RBj>ab!-qik^MYR*`kwYG!uecW>zAtTckVLcievnn zx^_&4aEB+S>GN*x#q3~2;?1a~^P$l}p^gkEYDlmZQZSdHOcr9^t|W%tau!U{@^<^o z;{%kuL)=|%!JR`^ermej%@%5VpoW6I6I7RuGPODkgux^v-VB6z5*EP zn;abQf+`2Tj1eOYi##DP$aT1r^Ve`r@zDaOCiP6AZzo7b%I_~&Km8F{sWovLG^UkE zBk!3rh3{Y9URRQ6PZsAZZ#z0R^13Jc><7r5`ng8TUs>u0Db$7hjKGi@{Xjnzd5Uix zIGRbh9Rzubvxr!AhK{p%mx2?$ZjuvqlFz8H8h)}=%*S)3o_Hfhhq+?Pvuwn(lT zAM4Q{+iE};Lc;JPv{WREtU}8zf%~0p9lu@tc7s=i#SZU>*UkwV;8gU|Nhbm3x=$5Mo+d%+y4MaP)deOw`RUWyX z4bgSXFqdcKdHUaZube6hog;e@Dv9!-{70GyFWRo_`oM+cPDVZ z>EU=6N3NG`TTgmkblX+T8H!eZGNZ3KAnY9J%gz=`)+=mn8B5Qzn#g_~n|}Kdu$*yl zRq=N7bZ+5YyMBJ|?Z9<^6LoXuK%MAx+;q8&0zbGJB5~JYBe!|a*aXHC>mnS*RNf+O*ea)=J-NmGWUp) zL}CI8)m06eBO{2;V6t;LfJVFk!%S&=EF1De6RV0vV!o>SJxN@XwotHCrDGS~mKl-c zy2QXWIBxXPE?=&=%xnMY_RXqQ$-K>q*$nbac&3m|Hui~Y_JF2W>4YYQ-LkbZyR)

BSwlc^ufNXW{66#t5*L9Q4sT=tHln@Uzk8U@0FxggL(Cb9&W8wturSqSzLLGc{}! z7eymxS2lTxtuzEO#t$pj&YZy7I`?{ISuZrx(VLGa?A?X1gw!9J>QoX->wQCasE!>h?l=X1tvUR%!JKJm-V5Ojs4GhdgR(FAV&5|sRxfvo z`;ENRN!so(AqmaTkrAqh{B;cupeFS8l~6*N6YH$z>rpO;8v16aaPYJ5130@ zB`sUe*5;$#fS0S~ZwY60pVgRw48kKrQktK~(d?j}h;Abg8^Ab-*)Y~z`URBX6I6E* za^4DT@SrBAp!t>4%863P9G6y;w)gMLR>CSVK8_VX(A@Lh|h>dRC!!)HF4Av}ywE zVr+N~ssgSyMQIIGUxy60yc$zixROO-&m>U`Jpzbgd|TGD@PdVQsqUK_sFoQmV=Vif zPuSaNrq()(cP)-Rr$kPIH({7J87QF;(tts12z)?46yf_YfZ9L;j}ZBUL2mrnvWm7V z<}cYknvD1Ww}knmOE)F3pBg=yIjrVY**TA?nM$fQnYWjTS@R{Y+gT82qNkj3y_M~T z{wVx3`Yc(X!+QtWP+_iINY=Xlz`uT@a9Xh{{((a-tP$1(EW9np& z=EiELzQ4JF> zb?-eGuT3h>ht`cSM;(d!%TI;o;AHQH2Vp;RMmp%{jU{_lTH84xLaT<8`!^^a_D8ef{_mGKb-C{8mKksFPTRuXI>w z#OYB?)S>naq4orb#`=a=6XcSbB_u20>}>Ztd-eNUKDW;&R(hXJd_LRyQ;+6>md zMK4UnK4tH^8vYzh?6fc$geP`DCj&Nh41F^lTuV(Lr=tpb%MPb8fe#EvIiUlKiR}k| zJ+VQ^?D8X0yL6lF=;7G$MKY0{K5BYHGtp~6%Sbb^;>WH#k4&Uw5kU~NJPFy%hO)(u zF^BN8o75&SLOW8@Qo><9p(y!}Ph87sFR^3ERt#q?!%X5*Nvycy#M=ZYaOxV2skd08 zof71k2+3tRFI-6>=e}}=xoijJ+%wAT2c#O~{JBx@C6qOe+ImM-T9=3X^<;?Dh1_ix zEJ);zE(MM4SfeCUT=32bQ757#@`x)cnttO|uREL|dT$WaA3&@(N*q?(HOn^VfD`A;Vg8FeEluhg#4Jx(|akj()w~Nv=Tuk0-4M1fzT16tl2+%=kf!L1TP6a zg`m+lDNQ5D5eyT{99snO&wlMPfIK@fdH${nN)!}Mir9xgaxDy4 z4;(J-)^n+_clB+lRA)Gr1#=oDThR|N#TD4_pFaecSb6stB7;?QW(+hbdyfcn ze{*UeO1v*`8|7K4BRW2aL_uDRtm0}D@V@cbnD+ekX=gkWe0ltUY?qX85a+5ixxo2x zP|8*Di5J5_cKf|t@vuRnQ%_ypc)|eMslE=yzo55gp{lbprAkQ8!pP=gLB)?~)7OJO z84oh(8OE1ChmV`NnJ8v~fs2{Bfhc8#p})F=2O!U(i(uQKo+vl)wIZ3AKJ^!2HA!$) zzr-zrTZ{KeAkkA)se)0G=nMr@v4v97DBh-_}O^`GU> zwzjKeKO+hPY4WuRSo%Sa2#>JyVdZ=k_ZdHCwgIknh*Inx5S`kK<|6vI$iDP82uD^Zcj-qz zig8&R=KT#`QYVE&T2p;63;~%%ngo4V%}d9!iU-bk6g=V3VWd#7CPO~W*MtNHRin3k z`Im|q(;>sutQ+zgxnaFZ!~QXxEzGeeYudPxJHe)tPRnBcHa_g5VK7kVj`#ybO{K!X zyZq_xZUC~$V{-kae9U0r&3!e`G%XpIrp{3WhcjdFs0zkO3q=B@ff&^M*+#hxvHmzf zwCAku$i?^*cC9&{EyRFzS6GPqeo$Y_hS?5_li{hg^cH_2mSC_7A?JktLU;;!%4)leV{8qEbFlR8R0+H$OyatRSL|gBYS* zLd<&4=@w*VeX=d~JqTH@)yJ^qrXa1?BMQ5tc`c&US!=uLhC^@DpFn5rAK_p{x5o13 zD1Vx#*)f*Xcr`67Zigr40iH0Zn;ILG--(R1R-bG7@x0TixZfU@Qd=?=Lx=-2W>l;E zU-3w|ZokS=LtGF9Xj=mUopz26@ot`Z$o{yd8_r{GEVH?Jsj(NuC=?e~LsLoI(0=S` zsn(~!Ve97>*nhu-LGyVB;I59j^g zS=gEWo;mzqeAp8~Q{Wrb)Z-DOq2ImD}vGjjYVf+rg55Q{yAO2Bf{0qPrkR*cv zkQ(EE;k}vwQO0j=1>oSfJOUuV0Aw-wE!P03EC46Jv>N~j`A=qVK%;=<92|fg7=X_J zx(vXVzw{u#bR2)indNt4gkN{Z!T^wf{K}5;tFC_;D)V;^gZ}_h2Bf+9CrCMV*s2>J zQTW~iY}n|d?|OuRs+bM6>txW;9tbvivnY#5GWE-SLJD)jPap^IAA9gj619%zwS_0E zqtzhN5SH=&hHV^BH_!k~Ikb3cYMtW5?ubS=x4hZ$lD^I;`Kzkj5U_oqmx7?UW^)U066TzRKMMU!;khJFZfzd&;^8$Do5B}^9+A@@0|A%b z9N0pXsPch*-BI}x$YMl34hAlpnbQ({4o|;-XRjWFXBoq`-|;{Xo`a%}T4h>7Td=r=Bn_>iv#;eE#>(S}oc={z;&ah&}%J{C?jb zqRB(!4N}}>2n~m;iNy$Uh80M9tEPw@S4FB?B##ZS8pP@?Q>`*eBAUgVkNrlHEZe?2 z5zU@n4bPLBr)j75Ii{7U_CL=`O*Mj|G2Q!puvk)5U>Q8S(tu)e9&5EX9(%FhOJ_pE zhXjJb4TMH^J{xsdvv0qq!bm}THcT2ab3C2CNTL_!4_q)JMQF6A6=({`pg3f;j-HuJ!~S3wBb#R zeK-o4zBV2&e*@Ovg^*|7z3W(Ov7+Q0|0twSR%@lDb4hx{;c>hb}6 zL;wz2guq8&6&wXpp#`+`J!G~O{v*GlmgN_j_eF1v3huIJB&uSvpBYJr8|LrF_TJtL zbMKrV71#CB3maC3D_jm23*WZe!|bE>3z(add38LM>9*#} z@WB=Z9z9jsd(3A@lvo$-w3Q4!l-9kO9Vw@WlBVq(Mr772H z_b`*<@Iik%WdP+QBQ;E;v#t0bA?L6|?e5{m!TJk^nNn2h{Plu^0kd6}T(@USV1EG< z6^@~a92cRoLRCods=P8TE|dlaW(vGeUQD@i8$E;LE)0?H!HvTgjHS9n5d(%H%enla z2~w`1iQ&4FjGtFF8$%N}`cV|d%&4tb-}-T3xJf04N#trJvOmQG$SHMJG;ujlW0=QDFL{LDjYdLCh-402?0@~xty#f%af-xHUK<&H;#XPo2- zQRdK!lPLvt2-g&Nrw7zW>`stmSurta zXR^Hupgmi704~{|5Sz!|o#hFLsNGG0-HT3`+>aqdA7sxyY|iD2oZS7I-2E}V+g9s7 z?fAF@J#yP?B?>Z97q`$RwLu@g-xk$Wh`!9m`sSRPPyc0`-5VqM71mk*A93#-BulgI z3$IyYd)C;tZQHhO+gfAWwr#GlZQJ(DH}Br(?0EM*JMMS?xpBKQx;rYex+|-md@?Kh zhsE}K&vgrxTK7o`D}!P7HsW%3v>NW(_RY$Bn!<=)wTweju5M-buq}q6*csOYy7lN6 zrk7!R?jgHB>dyX8*AabQur?o*$(yb0zp;h{It7+L=? zuwmcSa(T!9Br(IIG7Qti?H1>j$SUL!@)1H-wwPaA@X3FXns8V>YMeT2c#laih~6yp z5&VJ$>F%|8*6P1P?;40sjv(7>mlU2un?qxu`>J#6ea8x)(65J51)(H4i+WPyF@;>^ zrX&oChS2$i^Q+s8Wo_tU?=k<81ycia%Q4GEP3xn|rp4hhWFyKA^6uG6&QlXkJ?oo5V|2gkZe+QS8dxGa{6Y9yQx5br5k0=#kPUQ6t$+P~x)W&vbhku8gd z63%Q2&h9aLmQ=d=yi9Z3YlXx#RK-_yivUe?$)I-5mcy#6KjS^Qn#zdj21ePkH<~f1 z_F*jXtMMdXb#*y!(VOx38fjyea!9sZ(~r6VYn|62uCUGwKVE!76}S2bc+R;LA@90n zESOCL{wl{`%<@5cAVS!IoFd&&$d0U&yPHPG3`V#L!P=w(?TW$7^*Vsmg z3IlC%ts=W;)U;s-W^|lzq@K>`3V@;+0(?Jh`vXLEI{+A5Rn{MPuSORgWxUt_ zu4Y?5(Wi8@Tl0WM@kK????4s}Kfa95ESZ{z7edPnJ(+*tU>-f#HC{bfnB{@T;g$o| z+WYw2b801-`+Bn*K<|;tih#!#JRlqwipG*ekTigtRNw6*4&O>Em`OXIu?vclE-*n) zb@Wh_&t9ymgFKQYJ>#)EP4bx*zLxkNZ=ruq;XT?9k0yG3Mb^kJ$*@5ANVM@hMt&&L<_4Er zPVR)Eu!|)?NlMln0lvSG1EI^_D`Q?}W@noaJZ3JajOe}MS1VyH_!CbKzLw7^(Eb0|OS3jI`0&>dv0 zNC`%EsM+PLXi+`TDZ8and4W{=R8rMr3lw67J4CPr3qSUaI^qbloSb3GnRE%Br!Gzu zpD;uJ{`4{l;FL>InUq#}noR;ix4JSri-rs>hoqP*J+?7HMzKQ;{Is3p|R?(Gi)=mIg`~k{-W40bRcct4*qOD4sLm2 z5#v}J+Qpd7Z`Iij&0w*l67VIxGO>9e97)lhKmu4|F^bj>*G@7!FH25%7KAst3Y&aM>FOzUa`ip8xZKinaIj7jN zwPch`8fNBk%W066y_3q##RdiWP6ec9!(S!ubWk`74xSjXsOQR7-AFt9jBLF%k0qLo*aXHi)|s zI&Gn8VDOVhCm{y>VWDM}lKnnbo}i|jTJ4qB!J4Cf!L8dYm)+GDv5S&-`nwTP_t6N6 zi!ZObS7yaDaKj#%TT<)J&O-Z&dmUkMg}sw-meb4u{rGKYx?Sc9)A!p>02+9ToIlzg ztpU36cyGiV?cTp7uR~FZz5r4UE)+ccVH`?`2L&&9I-&MKAm>&Xx*2VK3KhizG+dTH zDKwenZV@Zn=1w=(w+o}i%+YVb@l;-7(JV7qVMx2!>|*L5&c&cG(198tk4EZ$xEO_T zmgU?uvCJQup2S9a3&!w_ey(J`@Os`E4i&XC2YGtGVq!EepPng-WcvkLcQl0N9q_0f@MscO7($1$83v$gGUhfnGw>408JA3{UiLI{uzEokHfk2Q2b~wMvGPS;+EEhE7FD@ zXl8x@G-W@cPg+s3o**FLlIz*xWDuEUQcCA>R{@l|<|@k(y>l&m<8-p;r=%}3PM&^i zJ#DrgR9V}Uc(`=AunYcfbaJ5Mx!z3D4?i{~6P0%cOe3XsRa|DtIJT z$PoD<{@{9!;(BjyR8?&tMhIRl0zdp?co(S76;4gFYUG1E{8<7*X30_(WKRd2mSb@ZGkL9HiKOSUtr;F4c&;4~Y6aM#J zEkq+CW0;D!773;UO-Q8j2PL?G52(cyBuJ=%0>!w!1`H8`(`I)r??=3Sea0ShScPL_ zUC{31@E=zxzOCu0+e8cVcf?84)ul!whs|tjXKYI6d)P)8nFEJ6Pz}&vc8JaQtj&R_ z%|ktzJPu6q1~<(ce#1M$Mxa&y#Y_F#Ep*FhLzgwJ5RmZp@K4<*!KK6Yj7g6AiT;-w zmd?Uk$el@)kaD6Wq*F_$$oiK_D%r9fY>g)kE2~C*3yWk~8-t~@YRgHv7|lEio#)+m zPB(q;uG{x_PSSqJ?-6>+;;}-lA$&Y_&&9mO2xCJqzl!q3Td13r-d@iTD5v}!t7xdz z#)zN7aBzGP1n_CfF-&XRH~_?)`s&9&#Fw!i_D2@(?zy^ETD=YZ3tT3YKK%wpuoAz- zy)6o#8>?^FU6k5CXgfg&@X<@L6a{DnI zSz)oLm0yy}-p7s#uPPl;nRQiQ|Dec~TQPm+>1lismke`0H z!6tTmOz!%5cbA*x{ME#FOF?teQL0)hcuOHv+Q8NIVol+6Nrl|p9w7{^)_TpQfJ^nj zFjemLp!C{)FVwI*kIz2}7H9~KOj&%(EklE5=v6RnHNWz`4x`H)%$=z6L;_Tu~k}e0OgHycJvUc}L z|8qU3wsL~p7@#eAY?^8dJMbozs3Njl3e5&1T`Mn7Z9Yz1Q8D^{o`eO5^5y3UKOIFy z2lvNtR};_k1$dg}hO?*XU9KNWNN|@+;j-H^59a&$RSnvhI|FZZZ{RyV)Mx>V?L-fg zKWA|+rD6rJyO6|jsxe%C&}waXhk7cB2?Y*uw`g@G5=j@!%`6ZSA?5V1*gl)lM>M4@ zeq@cP1UzCC9 z;y@ixejb(Q;C@8R!nzDZt;`De>jdNk`12N>mQ@S!zw+0gx&hMQ9vJn*CZAs^d|sOw z=bO8WzpDI1Yp$kk*P60YE^BH%o<=rDvQsT(WW03suf68x_H%!)@9Rn&PR4T;D#U9= zsp{z4wz1HL8#w6dDmEhMDc&{8+l*CfIt%*bL{Q=$`u0(RjgPA5?b)<)yJjx-_+%fi}@_ zhKZ0E4=S-hT-+6CR7SIA@)x64(b8} z-KC71P10-Yh+xizI+915TtgQV12YOt8lxS3?F~JnUAzx~=SGe7g(pI#gCQu=8^DZU z)oDbg`FrmZR^wUTR&e}TFK5Uo!9O8h>Og~TsY~&pTl1B*v*Ym^(0FhA_8CG#g0s1$ zEirPlyWZvLy`@>36y;;;JXf>=8yWrmL09)tAdH(+yqXUdlsai=bbd%`)Yk4QT1eN; zhp7cds&lSwRenZb}J$xE~-k+xt0D-Ma--tkM9hodN|{lr^d5gXGtz1u0TcrI_9&q;l2 z)*XlC^x4XrN#c42wsksBSB2sDEiLkhB0%Yx>`?L495;%VSm?Ij+5s>(&Bu<5)|s=` zYPF?F3AL1hTGiOzMh9rGn~9M3ot*w1f2C5=FA%}tQs($wnnjIU2;+zj<2C5tp>f0S z@v44C>y|t2_t!h^mt1zwh|ylh`GIjXD(;tD&ga@mpR-e13Ei-K(Ra#k=!_MZn|PQG zav=qAig5uM6_{_3uj{{AjvEuBXIv_g65mW(P@*Z`kpDzzMzhxscNgaFp|9rb*Rh3= z%4&dR>>l{+piksfrE`N#mH$tX5y~3cEwN^ z+A>2n;4eIfK2C0Vk_)wGS(Wl2C%SD-yo!sKk(8X?|8{m%?&iB1G*KKsYO3w5Xmf4v zCm3|u!7kDDYyNN;ukZ!ke5B{(|6W<}9sgcu zaNib&)j#Hg=Ry^Near_XnGOJ*0p!v^;||64gyQlvI)+Dq^E+(>W-iw@Xr#g|-ya>O zN{k&#h(#JjRa=v>Fj_OIoUXnomQ&_E_+Zq|F#&d0?y&?8Q?9C34+2KXce6?Ekgb=7##}e$pPQC>V%;aOvd;&GO7+OA3^&|?; zAuy1%uw(|>2|^=Jd6_aGEH>`c76sY&j?*tKAReTie!;epo*=5f%9r}JXhl3UlQ0irS?(;Kg0DS{+~jX; zh`1E&UJNL%AYq)DMWHkgstdQL6AV`3T7Jgh$igE`)~Do){bt+DVkJP?k>ezU)j`f< zVeF${(?X*#ZX97-NGuBae2TH91qqg=~SNL|B}M9}&7!CB%xH-bY3Yt}o>; zZB!5B8J+{>5UkNfWzl&}(XQd$#mc?}HI3p7i1AfY@h$e=<02AXhjy+XpDW6(^@8p+ z1R;l_x79q=(3pa0T90_^Fu&lnwAgoV3keOo&P3}J-STeQj`o`GRPFMi7qCS7C*HfC^?RBfGZ@m#62xqP8Nz~wv!J`E3jG(VJ4IQEZsf!$9t&d~d`xc^?y zJ3+Ueb5)!9luL;{d=O@!Dt?%dFEwN5dTWn8|znr?a) z3shT3Bb2qZxtY~{C)#G~s!>O*0F&wx0XAkc z-a&@2K7d&kEh9afUtb@hyqSZJGV@Wqz+ET}8ctuH2%WY%hE7>HCZ4cT5>a56Nm?JU zR&i0lhT=jO{;(GxA3vF+RXNAXRY-LnOA&H>Jes`L7Z_0v%6IC1Cp{Bi8PAeOBH%kJ zBt&>45_vaQ$63**hcd{@SkXtbKp(4q2r)IhS>ckZvzy2kVv#CJ90l&ZAu-(S{cylj!d@V+!rLDlth|Rl({tA%ygOXndA8)U-b&~7oXT2N zKP%q-a=A%^eU|r{G1VDWk$@ErzFc9nHT_n1XJ@QA$;t)GO}&xs?snjeN1J;2XuXj< zId;XT^KMXNXU>w0!r4o*z`4lTl_@z8r+-*9??f)I@Uf}WgiO<@*JPp)(zj&P64|n> z8CE^ZSB`2#qFy>@-|Mc|GC>->rcB%!8FpJqioYu*dc1^qoAHzEJW#fo>0AgS@Eo*k z*d5TQ-z670Zmf({);w@3B4P&EhdK3CigJs=aCa(&lCZLb4#TD;%>uJ9#rp$dKUcuO zl(gkeJ8ts1<+LdWgOx@@=H@z6rg=lcyR+8JaDiy0SDe(r(M9X;M`f zF^1_mjMv+Jn$pi|pCSz7FucQQ|F4-Nu_j8b&h=L?;*<6p2Lf|vKQ^a2t>mw zZyxdL96Y@0u!M!uG%|}7cFdn~My9Wy^5S8$U6JfcsLo-kj?=kdQ@N4MIA2QjD^qeQ zt|P8Kc{c-ZRRqG*KJ2A$E?z-lu}IR9_3~bgiKO%pF?(6=B|j-)sYbx4VQu&trfadH zOS(&n>6#vLw4$TdTzg13(Fxw+gA`V3q|o9~(P~3a?qtI31JB@mvuwV9` zB|7nH_dJZ2yGUt%)DcNkZj+_`D&+6NjcvXm8Yvz5evG1IyR9X{VF?hv# zF~FnkkN9I^98}ZYESvhTg;OT`Ltt@=tIyFrrBqGdpO zf4UM&dwB+g!(Dd>^*qHNtG;FrZ#nmf0F z{QKi`1_~S6m>-VCBLxBP8q&A0?4^q2$BO2O>g5Zk&njPN0z=w}vu6*Q@ITZErcN8( zu^cl-x(0@;lusHG{Lz;u%qzvqgOtllzqG@f@Os@%SVW3^EPyc_(?@ift3!w)#VZv= zb^7Brb*Il#@&S$`KmxGcJ11=cCCH;V8>Nfo`Hb{k%ACxx`sMq1tHX&ls2VzU)|xR? zU%_$n2lD`%Z;&WUmCTP9&>Q`UI;E#t+|eXJxbENpGDmdh`*o-31nr3X9;vftOzCTF z0O?HCB#8+kgabx?0Gj+H@8q+YE`Ruzz60V+cxKR4y^%C%Zn6!?vd?5M9an<9Z zZu%1u^vC&?;yw1F3I`L_uNVfV3XGXEW9ZSNjSGL4%e@PG*Z$F!EzkW+Z2u;)27oL$ z80ai>0~7p#r1|F`=(Y(%cp<>&vjT`DMsw&0^h{w3TGWX1?B8~1nvy6WxZk4h6P9$n zmduL=Bf5;J7OHh<5yJV4C3L7gD1X@7tW*)sWSDF25qVKU`U`Z$oSPZZp(UXth=ax> zdNYXY7tHDUM`eV{zlpnphD`kxs=em~N&xuEB zr7JP+_XyL+jFi`L(InI(QBW$i%NdqJ?NW0s8doe)z|Pt0H;7gB8q=&$9u0usg(F8S z>C+pG>xBU$GdD^YPR5C8$I7B4p?~u?--e4;Ihk+d;E;f8R}0Z;8M#OlFZ7i$9N#CQ z(^0>f(euFxVVq;CQ_oQI)~E?#ScV~2)T(~-e<#iLRP8k?l*>0Onp1kGRYj8idCJ_r zDv>Rxt%yx3>oBhlYxDB4i{~y*fWEDgE46?rGs` zPUVaZ&3TriPl9p>#007DHEdoJ;vo2A6kIiSaKpM|w6qd{T@7iSys-zwfqEhsatsFz z!x1jSymA2(B1XFk9LnF%8~%L~A2m$f3^UbPj)%lPO%)Yu2-`I%bM_fkS(7lM(34O# zHLa^dyt>DJET?yH|Kg+$X@-$-F5WO>S*+Q&WtYmEq2@=zXo%|kXNR15jfbZE0E+2u zT&QZ9uWL7AMBiank2L;8Qam<-dVauG&9nW4J1ngg%R3F3y@Y)2ixqJ%-8^@}*s4jn zh~?b6Ze71|Zr`(G${SXXCs)!-(k^DMRlErOY9~*c>PW%edajZ#Mf^POzjAux+Hszp zZ09w;Yy}bYO=&*ktjrbtA;o7i49nxgg+tro_04QPf2esj9N&9*sh|!IsSJb$yfFJm zf0e8I+zR$m{wI9fW;4h7_qID2e5Lfpcq3r!4~GrAk?|PcU<6EE3N(8z`#~un5jV2*LFH#3I)D--#z-vyQ0jJbu)> zQ2=S?@S0=25@HZF`9m%JG0KwIkF&k1ktN+mq>U>n(FZ0vwz9FZuyPRXI}7NZSw%KB zwx(-UPQ;{P#LUjdkj`(%ZxC9Q^2A!_`hTZ1#r0Z(qUZhFKkZ{Xf+6K22NJ~bBS3#0 zb1|vIi#}c?LOV5^CgnlQRO7EXQ3~a6(syIw>iUJ4Tp(+LrA(n`<)$IjjVhq^Dmp?C zx@TQ_0oDUatwxD%fHlLz)keCkr~S&tZHw0Ny&z|A13y;87}SP-X*WwWpR<;Gg-=bW zJl$Hhi1rJI?qiguNL7Eb)Y?Ujjf9Cqt8QPmbRO4=Z+g^!r93m653x5Y#hULf+qCZG zB78Gq5l%O}q3?Tq8|S0f zm3CEu;LimpEhjs38#u>`6Bp(p@_z^Q{!KXjf2Y3oj|R*C9hLu|oAoc4^8afwq(FBb!?@0Wj8`wKGvSA~B*|3dLIf1~>UQf2$k-U{ESeO8ul zK>uGAnExC0|JN>m?fNgX1!g*2=I>44&J6z-Z5Np6zJKxi(ErPNf%YHv3x81p{${>F z_wDYW@m=vR8wRFt-8nY4|7gR&^q1D&e}(t|4%^MZ%=Yh;fQi3s7y_@nA#maa0_osF z17i>n2w^_t%&>iXh=s^O(2io7tCvTj=K*u3&MQpLA8ze+(k8{O=3u0Rq_a5Y)YF2? ztwghqXVG>nR-7Z-5xdj7u!EA5-%gp?P9+?$1=GY&yk-j1Gdf2Go3NxCkTyfCMw}1v ze+_l0LsYj#?a@3KW+>v18A_P!9N0WOYd;ZkB~^|lMVzf$IgOXA3_e*AmZo~Om<1;l z)mjfP9`fTWiI++i3CSU);?8}(U4_JCPEN>JwQ>#{mL({oDg3-mZJ0u`|7>uF6xIS-Hv%;93fac zqpV4VbwStmiWEwj=NM=Oor(o~0elA6r#w_$=Zx}GFf$C~JsbW1pE(0710&<#gX^0%5TmK$ zcIYDAt*r9E!|yI?6H|2%LvZodIMP%S#EMHv=uh5@JoppH z4vcs;h`&l|e1WpJidR!KH%yPhPm2?eEWjc_UM$Fo6veW!X{xnZgCUF!T;06vIvIJ|7P_BUNNpGSar zg8}AYu-92iUkVOs01$wtov*KNOkli}-vD{PMd3hX5620|~$qvvl_AC;8!SQ-Hwx{xg{HH4@P|lex zHV9@gI#NX6k05sFZ-&4-l(lzov>gXL??YxfSs-KmmHC;B1$7;R!|$p+6ENBoj>kEz zMZawL>{A@nqr`lPc191yw(zKfS&>Qz!|I&(YuJxP(R?$Y#^p(0hUT4 zYoqq;GWyuwQK)~`Y-oZ)ezPB!bODKY3UmTMdcIZQd;>MAGoOj(q^26^d7*ikI7Jqr zEv~jgNh7XG3^NtCdVOq8a&wnbfcc169SqUULv4?S%WLq0z}{>6p>R_?kewZKyf)E5 zR}UY0%i(1CIr6R>*wto&);k#R>+V4M7&BxEV&ZjwM|s-lp%IhD)=!*SO1f_cE`fbs#W4Q%`MaL5|Mi~N8|Q429M%?YL=0hpk`0|h@; zrRl9P8yXADNRJtwh!!UR)A9o-(~90Fiit9HbTr?mcVndSa~8#2QPuV@BVcz{Ti|Xo z64A$ZXLfhK*sPzaJ?@7n3tcdv(~V8 zYeC?*JRqp;-$Z1ls4SbLwuHx*J|QtZmT$RX_p;J+u=Bg#B+-Qtt^IvMs_vT@};@Cw*6VNFEA}=M^E!^Ar=k zqw1y_i>javECkK)UC} zc!rQ~Ei+tmz#QuUZ8L>qfQmkN%s%akKk%4?Vwn9%Fx$9M1YwMixB;Z2$3n29$EJ-O zM-3DXtDT|x2r|0W_C+c?);&mBJ@A-AVwi*62jkY5{e)3Fgn6h#dgp)81?_-_;0BSR z_QbeXz0Ze`VDup3S);r>&x7_3YC+y?!9w5$u`zq~Fc0h{^>DHT0puzaTbl1Z;<^pq zy;y^bpNE0Ji3xzL!$#|4Z}eisFU6@DZ`VNGecD2C{V4<3nESWq3c?u!+LHP#FgFY` zyI_xdtXYR`bqsIFSkb$vI43}l*HbvWNJEJhJUOOK+0ot65@VJ%ArjXG_78ik{-n~o zVA8hfwc6vE%!3cCfg2efzTd80U{Q9d*hi~eY$DwxEkr{{ zVIJz72snCEM6v691tX!aXGJeg5{@L6~6IZ!siPT97mEM)J0MBQW!Awx>;mf$}r zV4^+s<RW@lS95zsRW^}UVEjv?4?}}01=vDQr12+%ZCVXvaN;oY2NpiC= z7_+hnh<&SyqImY87o)fz$k>Cti|SVPl9yVDF2zyEUK(KJV4Aw#1D;4uJ= z67b>u;uy6v>b+^pHGj^sE4+Ctf|&~$=Q_l`^Y(sk3Q}cO{gh)R$bj!M)3WH){fI4* zk6QP@-uQyWqM^YV8@k2oqWr|f{esXahjOM-$NIuSyzmWo*3G;%&&PuMMb*85>N!Q% z<$0HU&lEA+98+MUmyo>{A@}O=!X-9xly;b_6pKG}9dw#Y^p+dRtMU`ctE~PwKV8ni^O4Q_GrvR* zhkswrluc&p>{Y}5*mfz&#D3$#BZS#jB~hcP2i4>J;|+ilc)Jzk%QN5$p?zU`yL<9-_gu5Ick731`0qvqe(eOf z>_pu0s`+hDI9sfr$CHTwsJK0#0BB&xLB5S}v;Y7KaJC3PCmElAp}$K&e!ZU2=8THjhR%ud4Xu&YDmpttmr`WvHI9)@3wH1-Owv|5!pxYf_z~~^{NS8{=u#w2 zc+_wdydZbphoF9F9bcZTI-R_cH1;%Uv^Y`?mRzN1G+(@$Qno<$a9#V8l@+hZw}wg|9qaBo42Pd8yxMoSku%zPb=AZmxs-b9idpMxlIkV1u5B3~62 z>3ROKYWQUOX7t+VNM(!{z4W%+iSeuFi17CjQ9=0Jdr}l+!ecZo)ZFsAxu6vLQuTd-;2+yrGgqGFXQ6LW!CTH}WSu@ANUG3-Gb=gR~jxcHgR zA&wfuCZbHGdrgDBJ*#*Z!Z}s2JH^bTqhthB1P|l0Uz2+6e0Q$;$}HmKhKsLSkMo_? zY4MUQ(V`hx&22d3E~94{E_bX&^`Mh!`lb&vFOGF~aDSdPh)>d&4JM;_EYv*>>iL2dOW? z0L#Zs`IVhhD1~H&v2SDuGhe2pQi!Mnf@7kgRB8^+n}UU&)le7^msFcYsh@0Dct?xM!Eh19$K|8A?R9M8jqTRsrQZb#`MM5S)yX|k zFo}8NjXd}Iq3#p4kT1x|QjWjx!yGLcD${RCL-13y)=DMqK{e57B%&17oCPZ0av|_G zv{tJWvc%sMGZ(f>Wr^l}Nx0Or1RtrV_mi33O4+%8nzyRDuSI+u{XKKNulvV6YR%6< z_q8rhl!u=V()#6vK=OurLQEc&1^PKk2U{V_6`d6k@zhH{w|9l=-W0N57mK-p-Qzd9 z-xb`U$y26bz)$lk0&<5BMV8WVMyyUQikl$GqVo$!&Io2|C1UJU=LeW1*iGu{}TtDf~Z4VtZ0S zzW4)tN_)F!w{?X4^Ow0fpj9k1jrXo2fZ9p%#w@XdQz_t4(0D_C!Wp-VjyY2crY%<~ zBiSNqNcuJUn=2^+IPOjyX_eM=CRO^xb1J&Ka<2OQwvzTvAMm8?AV`ecmPR2%j{5h~eX%DcX1_X;^`3mY1-{OS`NlhUZlEgmgvn7Zi z>?^^GpbM|qq}jidAlV=xkCQUjrygM z-FkmXR>j@a@sC3rp((DHAERAR_KiE1!7(AbI0)OZ@ zCM}+V(Pr~d@UeKTZFX7A60gazj$Shdxa%bN6^B;)C&W^seUau!is67E>3A*Hdle|$i4omqc zWl4irO<`2ZMnlI!aphmosX0s5^P;DKI;@4;+fyePqI*K%srV9U`(XLIvbt<8e8Cijd7(^NYrK%>@0J~%9iHOrR)W(_U_-wn^JvlR#s96; zqrM40;}X*SCFB0JY>?kA(+gh_rioh~41eg}yPH<}V(wJ_GSOG53TJ7xfs(un(c4dr z$w>|3qz=1P&HuGhydr>K*C23@&KN3r2Tk!v7MbBir-3k-bRJ)8KF%!eE%#;cc%$Gb z`O;w?p&ft>JK0G-r@1w&`9oJfRZf8i1=uiCYE-Fb4$P~ZXgzGTDXen0gq3{!XIY^6 zz1wSIyiV@a*M-T+uU2GlWtLc-#m0N}4WUoFPpFD)nz>3rd4KH$-HcdW%E?&qte{o| zw4*V;WlF-~L8tmewQ@UH1agQ3M8-rI6OwrmhTrEQjfl00d$s28D zU`wkY6CzY29m4@9)by_5Z^QfHo=|PnS`=Z)o=`GWI|Shjvuhg0=VbKv?^sv*t7cF% z#t3;`4T%6Vo(gcoK_p5kL|3n}q{BhYQn=-y8_wpaGTh2UlBBVt2wk@oVSf^w z24km(%vSfw_-9wwhY8Zw8{VY{AS(f0L(fy4=b9I+S2br@VkK`bD|EQO3*-}4-Y%kz zkjiBUMN`tMjW_Oe>b+}Rrr)a!wYS~_!G|C37eV<5Xjk!*;oeP!hz#>a34B3t^C5c^ zR58*=mtZwtG*TC($Akt`G-r0j=-lwDnJrdYk@sIHSP=Ak{(#15-Jj@^d;#&(+kYQS z_~&M)3S80cg#A@Bgn{f#_a96t#ch#NS%|1?Ac*dCXK?$nj?9fvt=W2YQEWZ>0@{&+#ziHw_H0jNM)2}lo;NzelR-7MB#CYOo?-* z*M3$&ht!&VTEX2>pw>%QfW1*Khbx7u72k{Pi>K1vkrWBXGl$bwr?;@Fb;CQER`S&X&E?sg-np}q_ z)8XsP;MK-6IaWKJ)R1)MPrWDE&{3v&B?(SvolCgf|FIITT-kdBsz_@( zPuR>+DpwRv-#WS0XzW#7g$i4pXg+~(hlVk{fo}2`@Q;)Lf4!xacT5_B2)5Z~cX`3w z@`dFE;id6y{6YLf5`Y!_-varz2QT_GHFle<4q~LR0>_z|i(rUsadMLOTwrr{8aIa! z2mur3#`4Wnjldk7`PnoGlT1oHSRI?d| z6K>Q~J<@bRfiGth$c`|a7?7L*oB^0VhyQXkZWce>6w~t&#`|}AQ9*xSg17v$h`Y%z zJ4wqW4s?CJPqnB{k)ovE z@@KNq$j14@&#!Bwc0L!daMQMy2KAIi1Zn;klBTB+f7BJFQQ`$5xEgU!L?C835(C1n z5cWsvq;QL#AcF9`?f`1UG7Q0Ee`vApfCKdB3&>X?$k+D`g%kF5>!fhU;2enC{oy8& z#hVG?Ga5Q4CUhwZIzI$D|1RTu4SoYD2q$$G4f3$2LM%;6z@T6SDm-PbJiZ)Q>dA@2 ze0$?`dnXIh>Bfx8$~nR5F)tk|IZR;wN02V<*4H7qd*!D!eWDJ_iqe{gP8ZjhePxXQ z53XHH*u6#P7$Eupvrkn!218ZkFDvAu5KWvdB+5iF|5oT`YUTEWCJlyk!t8gP0i@xf zc5)vH>5>2n=jzjJOo^uGbL{mSoIad+=mU^ov1}%^r~CKI9>g_@l}3*@kjE-9tJRYU zXg=@RjCGOf-bk&xdWWX&zDG~Y33W-sRv&49(mIingD7bu15qY|W+sMPeWoc<+Fxoq z;7MrUN&0gEBxPES;>E?IlGV_$ffj468|_}XzEfF_oGo4$Zl#}aZe}Wk=(1eGiX6go z?XHLHq6+f>ps&|j;2%Q7yiG*BJa?iDmLbtcBmmmeExCLM=<_+fsEo@n(2oKE#W}tI zb``SXYzNG)QDiI)6Vy!hRi4H#z!JO=i(*A0!{dGx!^>JCZLf%XP-xurh5GJ(>+JDr zBN8PTOB_71wHVee8%VUE;MVWjTOi&TlEW+0H4DktM){5)j0PbF<*cAncetg!UX{-` zJqCIw_6jA$x|aSg|{9%}{4(;!v-98HlYkPiE$?NoOoO?qQYb zrL;3WR+odlO>UHdz5Q4qKIO&6F%%XVJI;#>3HN+?OFXFl;gAFj1}SK5@7%Q95iFa~ zEITWHlc*d33nl{&%LneXN1J-8*LAqf~(k}F{B^7 z{Z+HZfl^`Z*v^Vtg&BlwVcj*vkuaP)fF$*4-bXZVQ@?!(jpq66TjZU6h$BPmM!t?< zf?^e&7jPbcX=NoQ zeY(IwQhpp|DDm`11iLLFEYTWcNFCG#(lpy}cpl;1F4%#ZmHl#=tcLl)7(&&pZ4Q!Ru`diblLrAqA17VFWbkZu$YB?2dZ)P!#$+H4{PtTXJ3)6M4GnDHw4MZgy@r=B4Cfcg$>O{#YaISEO z3J!n@eTioBrUv<1I%zXu{%;@7bCU%PCAD~^D4uTrZewneW;K-Q%I8nF!S#+zewh@; z-q)dGGwX*(&!HxzRNY)2gv&8dA(`onbGb}qlVE1!`t)Fhh+4gLF1Y$OdA*@*$4KLg zrgPK6N=%1SN8|D(Jobn(*`fw~GWE3(j=jN90TSVSm~15z$3fnl&KH;!{x3Lg%T6wC z#y$V`R4&$p)eSlu5=CjW3GtFfl!J>OQ&7=ZRe|OD3i!H-BpgZ_h3+5gj~AbwKI02| zENv53n*oYJ*!$KK5B1>UZDZT{jc#dciXF@gWoPnG^2ZU8jx9hAPl9&k8Jrqxc0A7~C^ zp^UzHfWC2HS%q7K`G?*?KX}mnzP)WT(9NWoJON{AqJo{F=Jx)aSudHk5F_Qm&P_te z^qY%q+E)LDxRbBt|Gw3^**v93Pqps)GqkqZI#gc-$}1H#*$`8>bx6X)M^}pfeW;zW z?IOEU-4#XsKVbaDxTe+Th3kHUOFSCnY=MyWK9d$DZp-?rT0pct z?{x})Qlv;f(4Hx)#nL~(mB^}9JMROdLYu#cYS+Hs_XyH37l z<=eG&ihM z0)dQoS-qnH4VY!&nd`Bcn8L71?G9%g!rwCcF4BfF*1@~@Vuml-K ze^vj&nf|x37blv4izjc(N4#G9ebKKowHG&qa{vbBth!)-Cm_-19TJNkW&^A@!IUX$ zJwTE!A8xgBZ*DApyG-JZKoG-mq_(!?6&)$>{pk(n?&fyqh$}(hr&5$?`V+ zej7Vw#5R1;b_E2gX5BV;{kx;7GO)FH$U-4AttJ4kh|Th|x6!mGgnz2#JNa5I)bwY0 zsxVZ@AS<$rdCUkHkU|8FRgW2gX!asXkBw~sq}S3Dog-*pcODN~Seg6b%JhSpx~;{= zm`r?+Y{#e?uMXSYv!D9KmBF|!_Y@V?Fk*(zsH)G2#V)4VNWhTdR(GI%{ z+f+YUDdVhu{Mb1Y>+8}?tf$N%aO%Y}#gxD^35%%*XqgCLr32#DjoUKau;M8q){TR9 zYFToyhJcZ`ouU2Gt)eFO-Ey$M*3)CfkSHVp`DgQXk;cuZz`Zse(KS)J05mzuIz!Cb z;GM~DhiJ9xIP~6wJt+J`e+#bn_u!bfurV1+>NRSq6_Weh1oBj(87g!B$#Z8-!&Vmk zV1^p+7os_q=!onqdN=5H?3uOVN}4bQV*S&f4wRdOk6Psvln&z#dIU10WIM{v78O%R z!82C?5cdA5gTSdl$3w;U3p@9mZSxiv`nM~0p5f%}u6Zl^ncB_2&-7b~k<*><_UvDS zw|Vt6wEu($A~Am9GI#(aLv^JCdbh1SHss`2wo%-<^O#~NE9}1brkk(i$pFp)+n8a- zMw>py6U@jmLWu5Fr!PgBRug%iaTZg7$+xW5Uz{z=t#4*ggR|c=4w@eQ3f( z8~ir>J^e)Gl&c2D6s2Aa_Bm0)*ufD)*&b3D8R}~>D*T>|xUodli6 zq(DeM^V!3%FW$BMF9dTV6W!$2uNkIGW?gjBSWWpO?G_*Y{RYoma2$AXW2lnf83al2 z9b!nZknSCVrc9rqe+%`qjd$rZ1sS5a7vS77hR*$%_P#}Wr(Z-Y^~Yhfz|-~xXU2;gJv3-j?KqQVjPR70h5BC8=?l013=x?6V;9dvVE0HEjJW0Eyl{w!x@)E{HJ*Cz2!PlQ*-aNoX(-CHDLG>d|9y^if@^Z3dX|RtrN8^1n zIm?vtY_^e*{FfnRN>Kkyjud_F>0xqZlGfiAuBG8`N#{FZzML8JLmK7tM(NfHXu@7T z`$Ch_iU#ynv|o8I#|Ajghj5+3vl@o;F0qI3kfA^R_*%!G{7ltBV}twq3aZ=b`qKUS zEL)^FKBZFX=<_Q)Tv4C-5Pb3?Lfze@P#tu>?$INIwoIBv(`eqsfm(w$@>t|(OZYW4 zT{LboIkrpIL+oOwy|vM^NWV(83wSFXbm0=>^L*?-p}X4SYjM9<2N4xM!C&lHE3{VN zof*wP*j`JsA7=7Kec|dVTuZ<#xK;$cXSL&~yD~ zS(9bju|@mr=7#W+@G=e&aQa4kO-vwKoql>_%(cE{lVh5w3({SUqSCz0}h zn-c>7ovIupY;5fRK)`?{JAty)zfoUItbez){>|w6j{=^5`C9@1Zwb%8&H;;f{&!AS zb|6#+EbICID|h8&BH{eI+LaSXtNpiH*T16ucVgGS^IV)<|5@=0zVFxd_xwh zm|Ingvu@93Ro&p7{@LshFP|;^2U5rndKj+??H6+2b`z6K>S~$ZX{1r<7xXE>>1J6h^V|J5(9YYxo@>5o5+3oU08%?E zm;|HcC6%I52@=twQi+n3tG86lyv4edGf1CdX92euqY}k154;NcF16o6_}XA*Q}?n? z1kWU6!O_0 z+YNGayc*V?N5Y1TI2tQut?f3YJka~|2HLq?`~F`a7{C?y&&m1UL0kXBzWU#iTYuZs z{|CACw^j15a47q~;AH~=M_wkbf8;RfB9B1GcYM{kpbXg6D0NZCXHmGbjXsU_^J-+UH_bA5-Xkjae zV!!c7lh9S=M>~k+yTRy7oo27oLpuyj@+;|5tguWSg1Zo8p;QeK#KjPoKB8f8gbC&%2#f|ZJ&CK2{0j)~A*kS! zvOkGHVV4e`i|_lO=FAvNwS#7sQr!pAx)^}AII zIu6JO{SGJy2d2v-yQ>M|*+3#bux?o3cPEwl?IwTl$UW}3Xq%LXs%1vOZu2i7*2s%u z)NF0uutjK=9q1sudti7>9V7^d^Druf=*NSkIH6{ZXe&eBYllAy#KVGFg>j0@`1zCz z${Gn6!=D=xKL3owlxar)&eYXSzd7zca=UNGD0ba0-KxE>yjJ(9=2;d{{ZvF> z>7k$g6>zKlxypEDdW`9qzziNhk)9`mvsb6=Pe2t^4(iG}@QYOt9dTX)Jkv5z9j`D1 zs*>}UJ{r+0eg)VuW@XZj-{Y3|r$UrJ@#Qp~;it5p43`^=Yg|oDkv(I54pb%8tD`d* zQa?ScJzl8+q1>~FQ=D(rwsLXQr$gZT=i(=m6G*_T&TbKhAbc|_(<0=NEOurUPa#6# zEx(`MMpSchdh z(DTDR62HwOKvjldlp1l>1};0o>VkU=z3j_*!YB2j23cDMBWZ$g3~}rujSo$YM=?t5 zmcI=}57A~KpA6D!BDN11al-Y736L;&6ZI-LVk2R|1rEZe6v|omWA`EY?D0Ur_JB1E zz~TtGEPzeQEMuyMZ48|WgR&Yzqjv3qX(HC_!*yU?hi2@#c@pl3$*TsPFYvZt*@^JX z({;qrmS~xq?_Z#~fX_q7F~KtiAv%&>hHmchd*Tp9jO~lPa(EKojmPNRvHK?fP$uQe zSy%0-Zfv;K-KP=+1-F(I#&#WaU4zLn;@JXeAF8`0?*Zu#^w<|L#P}vDNO>pO1 zj-VMM;)(y~OKbRs&{auLUxt5$yYY`Y;Atzx4O`}GHmH;iSgX%J!e|kobZ9cT=E&Vo3Ed|pS7PRKoFLRwVt>^UO@0s; zN32H_n#H-;I#rZrDRx7VN4i9QDa^cxh!vSZu-6i$PFQCl?G(@TYZta3{*};63HT`p zcVxa$LrKXg!y^`V5WiSK@#heN`8Ql2CykR2$L`rC%!o1c{3h7N|}?OBI;GRA#14j;E%ZN^MhY z8*Bx9P=9gyAcXJBy%O=V7{z=IVt94`u;@Tj2yz&x0eOw^q$LQcvlQ5XNA(1`4&$y2 zIdCKpMJa-^7DRG>d=#y=-f7M&6E=l>65L%WVKFH{&K>lK01#f zjxy`X1Esq@AefHiSs@>n*yJ&vhKLYfgEQU*rMDg*<=0dXT9spCtl%`C)_d#1H)5=C zXRAQop_~{Us)yS^_p793HBIDX=K86tiKC*|ul_Rfkypffhla1_#}0ox51M-?;26_N z0}HVOupTc*=WYu1BZ-MyvPaH~903|sR53BAd7i(+EsP8Ps#Jo~=Bb=TD~5xiRq4<4 z-VfWC%N^N`WA_)aA+4pp@7b7^HCF5bO<*OHzTS^`^@y61Y{Ci0;p z;D=VX!8`P9YZ)Wprbu>bD~ECxav6Sx{2By4lUuCy%rUMww;`q~;eV(PEx7=^WcP)7g`;Y$d$&SqW?IUXbNYTzOD^wVcRGGQgwgK0Y> z?0Uy2#xe;m=~n0=EoD1e^3|}E?T7UJg#AO>$VdC9a_#5(m$0RqTc2p)*UxHo0U^&rb&SR;KjxruIWOc$KhfW zTSysRmcc8+IGNFjR@8X$;13t1;Qe}n+wEQvnBO)ANSO6ALR}vuw}K4XI1@sUs1a#r z^mvhnBp9IjT8Km2J57BBSLO1>UX4sGqE z)zd#db~PIpTwNYC_4SQPB2|xbwy@5|^J_%ds9A-o{ealFtc}X&taf@ zzFJeF_u5_;;c;JcPxMoyKSA;B6Q)-?6vPX>w{W)9X6s9NgovdEGMbLsDJw~3`ZePjqojvmg&1Vj#zb)*;E}klnJEhXgGeQC z4@Eq{izWsxaRerRj6kswnQSeBYoHN6h1jbL4qP`(YIcwTXAdjZ2;Y1UYZ?^ZhG_sV z)(Fwu5b-!LTntBqquelw^(TF7&@{IUt(pk}LuR;j6~?bATV`1iSz0b;j#$n+t4Gl6 zpkv-5r@lt{D_<XZ4n zc|=;*Z>|!>T{=%O_G&)u)nz)%YjxgGa^Y}_U!<2bD*FtICeqE##N zMMRhoRZqX1{b5*OlY25)RBez~+2)dSyJB57TU5oCSLv*%!rD~h@18W*IGRsWu(n^m zTmmT&(I8r@>T;`~jo{xjKzC_7o+mDdY%!uE(*ZC_L^R|7Ss@l6yFow zBng%Bhb%4gg|AM(pQ+Tg?sw(Haz369-#`A9%hh7v@fP}^<%%j=HmWv!hdxV?&AQb* zzdl*wX*I1a=|Y}>z!9*RC6YG0Pt2v$N)4lQ2u*b_q-*^wtb2Y+vV`eco z!Ut>?+eemrF3b0Qtg=GBE=eUjm0)=*)hn)@f_>oZrkgk?o>Y8 zk}4O+$1wO-QfbZL(Kb4nCpRPDEH8h;gy&8IRbo+ro6g5N zH{TP9jQMA7d#>L1Srhy<2%Q-yO@Rx;DJ{N=0|@yU8ZNFeCqVo$6Lc=WhNnU;={{wz zEJ;=@rH8gR7E|YeF)JJy(l9GYHgk+UyS+7Y`)PLTMEqmM7L@c$^;emr_08_%XLDkP zo2ixL2w^V+4R#@f-$IW%k3y7wD1MSneLErJasD}3WXs))etyUGmyg#w1n*4EfC&E_ zEv`&Vse_2qVOezo*_S6bvlE#EK^VVGDdJF~AoOy@y(=40YYw z`DswJ8BozoM!-4{5jx5HhNWh=9|mRzVSn@@dH8C+lR}{g7L#O@Xys?H{-`^ zKw^5UU#tJGx&D)LpQsbIy%~D^TXU!;4*T~QmrlEHQ*5qJd=we9hJ*a^2yx{Vy`vHxx4`K8qrb4&@adr}|D>-mkuQz7s~90oyYd0QBRo{Y%{o`#;Hp z_LL!YWG;FcZ^_39R(}XB^pw(nBlcb;`@OmXdNk1nH7vdNs1}VRZ zAuJgT4k!!^B;X$v#0qhthDcL_F>7E+hZ8O_TM6ykEgAs6lm6(xd>LjW5BMu6GOH`xdW@X}0pkU0G< zb4ED3U_%fHC#fO9oJ??UC(N>q5dtSX9FLL8;i(OaeuIeT z#nML{$G8`+%flTaY)dJvO-`-JLtw>!e&1#u(^X@8Ftr}Fv@Um~WfQ@X6OyrJrO1_y zE4Qvb&~9h1bhFu>F5fy6V9^WkCzbep9!reUlO_-^Kd3|o{o_xpo<6Kq>MZcy>{C7(% zB72Fb-oTVMSX9@$XWc@e{Pu{z z2NSmzWefO&S3M91TnOy$=37vH?vE^JG`&$h3nY}2emqh~J@+6BzGa0OhO!l#rTpRM zLpr^9(&XyO^qCX+ZN>a6U*j{MHi8kCQKxpUh)%3dTFNM!g^8esHAhxm_DJywUAk=2 z7$6YC9)iyo_+CUlIAmG<{ikZoY-;Y^`Z(^RmYSLzTkQpOiBJtkHo~SR2PQk1bO4i7nCm%|A z%6Z0_L0rh7-CVT{ylJKJg7yGOMpjj@J83Pjg<$%iz(x&8*ed3gdYiq{x+9}9J<2En z@#~FuDD)$H;S7@XYc`JQ7&W!!KYzj{{NHmv+)kf@ve;JGEd1?+H44iTd@u6L5HH@4 zU;4dXc0Lw9ju!aM{_MhH&ujKJUaTlEkboj4C%Od8x-S}AF2`* zvpRz5IKsNxL_$6vMgA$^R@@2R(rrRBAm!<*PGVZ!$Te17N|>fbOPOZgz#(>Zh3Y&Q zZBgdjj2BT8*M{5}>AHCK?G2rQ!_MI}K*tR0_Iius{p(p2WiIBJedk4ytmCZU!TCqd zo5ELvpU(jfzh72$S${!=*2T3O^m+fjyoJ2XQ{eZV*t@g@EHygMxx9IB)734D(&d0u zYGj553PG+$&(2^Ns{*peQsGcG3RSA8OTj3_ra+Yd%Pf{;jfJ8fyk9DCI+gQ9kdh*L zec65!#hnpU2TW3+n{}I|UgFY2BT_R9lq$&-F9tLTuz7=NYlo&(%_(n`iXsXpUX-Xk zs_#?GpC{u9V-j^v{oLpnta-BDhUNY0AXw1C@P4M?5l2+)5ieg-&*d`G`ZhM-V*@H= zvu|=vdrS*6oG`NI#-6(O2nE4uYWzslD^zsIMp;w1U^~BVtDd7aKQBw2E7Dp#Ja^N& z-neYMO^3Io9W+|;L3>O(m(q=>1%k$V^6wKE5U33zUv6v^ZmdE)d8RpPd| zj}us-w4{`D!~J=Uk`xm}z`zA8M2?b3RtvT!+kID*XePmUA#?*3wbaNQ(u)17{h!6| zdS+Tq*?p5!r;Js`=~BP&j}SFm_?@tb2to`6eT;QO3O0Y~m2txotI5 zyS+x6@oPRd2Sa7KrU_x-X0H&wK@AFCj!!JqMa&FU`A4~~loM;sbRCvwb_R&*Y{4ey z!*uvU(aom^F7GLja2&}7V!xq}+qO_7#ypb`Kobe)C5v^upVt%-H?D$f4;tLCSE1I3 z+xmr=0>Fi=e&IZF3-g_KD&)@`?}{A@lt@jlP?fvdvv5dsS=_vjsH71h@1lEbdHyQH zCi@bAASc4j%K09?$!asX!rxcYvE>99%N_UMY6Y*gWs_1cOp&w#Dr9hm!dT!^Yoyqu8U{Gp+ z`W3_7qZKq98m?c0Lop!)Og0hasw3H?HH|WC#I)(0ZaG>yTuEm-9edkiB{r)ZbwBr) z5CX?|j|CwC?+$J!C3?L*R>}UfU!5`i%EF3ZmG+N)OB*)|3&D~Gsn9uN_^eLvtU zTL@!C#JQL@WajFFp_ zH72Qyc!%pk4I>2iY8o1E?p^^uN0@KuZyxj~bA})PXv-<6A+dn)N4a+L_H8;XiyBr3LcjSL*|DESximTV!xRn-KM z3UP`#h|YHToHop_OQQKb&z(pE$i$)Y5J;;kU>r5T^+8zfZfHZi2?SifOlte`q|{ocK6|< z1VpaEjynTl3^7Ueg1%=#XTcem0K^{tPsoQ_zP3O2{9}31JcP(=`81!+DKE|bVthIc z20>_<$$MImQGb*~A^!)b%Rk%c-8RgpdzCs$S|)2K9!cNI4!c9;W3ZSc>jn3B^v z^#%p2;E?UlVdmcU9zV{nTTj=@^6GLP)<1$~>ayZq*~;k#T5Iw3+E03Vk-zfBA|lVI zK!8>E@s$+xIGr{4f4k%Av9p6GCNYqN7sP~{3Iyw}6qgIELzthIy9Pg8O3J43g<*Cs zUZC89s#Xj%E49$rM_xi)0#yuzsgtD9e|6e(1!*2c!d3UaN#D)$75x+W(`dQ*1Nm*$ zq(OuDBte6?<}H=DYK4Aph%Jq>TjeISLp+2vuXW+wR8Er&IxBy1?6+-8r~V)t2aWdn zim9UFcWt_nY=$DuqdsZftYMPL%^4w`jYaW_5dv|MD?3q-Tmp(?zNdYI*muiwQ3UPC z3w2k!*6+GcITG=fGI(0bgef^XJ($wy#@`Gu6CqteOUuCr*cXwQUS*M(vM0fU!6E6W zDIie5TEngO2?`O<3BTW@_2`qAMz($>v?osqUwI-cmLs2Crt(F6`crHmc}}k2C`*?U zOYvug1Ld~}L8x)MV26#!>a1#Gmz@kzpA#tUq7P%VM2T~M5wG&^SCOx#AGx%q&aZlZ zq~gvft-XK0xf~lrwf&qFY;o9rQ+u|Wh=Ed>I?>Zyux}knm9kG(Vq0HzT20HcxwpZK z6Kbtvw1j^}tH**V(8wSB98t;(%KcKgVEtnPXv`y9uv zm~U_+b5)etEv|*JSj_DXW^;`Ip$n6UyBt0`xgS)a9Ph`ATCw!d?9$;{Qp)e|Sh=Y` zA;9*@oW@p8VVbO$uFJ#?0WyfHCxCr>eNy-r zUY9Sczh?qZ=<=Xn6w!jHk0g}n^Ihy_r~d>&QBmnNQd^P_&de~v`L%4VKUt?tx1@W` zk*hnbTeefpC(XXbzh*!7Jyo17p*shf>uIXmHu~w~&n&ZStr}Gw_EBS8(b@KJIFGQUeWp2sC!I{0?-V5 zB^Y{5$wsTBJ36V!@ofRo^A5jcb+X{sJMbmf&V0j7z_UcZ&!29Z3_QNd#%0?`#e}|& z&k_zst@568>~a5Fw!;fubpZ=2IR~nF#R7*C{laGUY(zTK^S>wVE(oYILy29AY;FNUpX`&)NJ!_AyntgeLLe!Qw$ zSgN1Cpw)(uOWz|zDcOY)F1AR*j!MNg=lcwPWI#%R#ZmmmdawYDZnF1j>Uz)hXa>X z+BnD4#>-cqTT6v)jt>{!(GhdATJDa^GYOvNX2gfd)P#$}jhmacmh`XZk@CW)!94ff zNSAyVH`XfRa+q0e1#c=L7g)ch*clG*128xqTkzFzlyIuDu)YyN-w?fS%mJ1zr7cXh z-%s8~e$ao05u?Y67lRQ9pz2CC;x#aR3v7?FT%^m zh{Gj&Pl3Iotbx_x`f^-5x`Wlt5+NUx0<6!sR-?yn6SKX4uim<5!g!O(4d+!;Ev#RY zo(x%IHCPyA*c?~HLFgV z+8an$3Gn#{<)*D1K_xCLArH&GHd{8dFLgGqJSF1I!Och;88tBtbEQ~YMYe2;%oQ!} zfuf>v@Px_7Eb_UMtyy&W_KEw1O>NE_7hK-Evu{{*^hjeb)-}>OEVp=U;C-L1x-u5= z`);ww(gI*zc#}1y z_jxGZsO*MtE)A*}7mRQZM>}W;RF108o?%(%-DP%<%)IJc+Gj+-m~0(2b8P4_I^Znx?0;IJG6K|D+a)+XVzgJ2ZC$W3dx-20?rLMlzMbf_9qRL#4S35 zK2(w?nR&<4lxNo$qo z`kZ;I&)$2)en_e!ZQ4AN!!8~>Zm40R5KoY=8+1SS5fswfUtNMsc3>hlFrAeZLpqs&vFf_ml;@ZHFuw{aeOXh$}CSHIQW}JaJUdx1)cK@oQ z)`y4Ui&CK2vn8)a^1(kBH}Jw;u+ZmA6$pFB%ed||yGrkNcsk%w)cQ01Reyq5WQAkF z&+Vk2Cs*M9Ac9Dum4;NL=P(5!2<@k%=4H*v#df>S^}WD(0Es8*t5#yludF>*fu}-b zGvD!H*ehC}kIb(d_6>exHttpwY<4QNhZyLX#YLXqtJEfOFB_d^^jAiIQhX8XQJ*OV z=ZY2c8bXa7ZHa&&M`4y;LcNz%w+prGOG-~K2FZJ3oV`9Orn;&r)T|DMC0QARKq34N zn)ucULd*5zdyM|-4c$A5*g@DRZWmNBugs8HDhv(+gbWy4N6*2}Zuh!ecdb8RVNL}>8g;;tMBC;7bmIp-4iDP=sABg!?5Ca*-T^xge( zY<^Hl=+6k$vCB>p>tY@Ch(6qdct&nOe_3Oc7q}EeBsxU+(u6I-SkRD1triwzpR|eD z4o6fGQhoo%%PJP}cw|;@cQmf1uNOb%K!+Vn$*%fk*IaC+x~Z_?6oX(*BdnU%MB<%- z!mFbo(Q!12jO^lFW`945T-3zdG5G2$YS|-mpyqh;f&){>8@%!#yiT2rE!%3j##GNL z9BiA9AYAdAur%Cf z!pG%%xpSq;U?yJr*-PW@+>>u7TpGDo3gZ&hk5!8r?lX;y!6pH6F0r zX70b`W46(#u@8Aw%Am5u23fl?jaG6g_8aA-c(}=tEUd&FHikj>;xD(Z2J!?Vh%|T6%v+8|}kg zj`dNM9isu(jXdG&v7Ng#tnRd;GViw2Ua3#@EgFJxOWgq1_Y~B{QH4N6jD`~V+AgSB zsAqUicjHpKmkvMGIweOp`Ss{4o4vd4oPopEbqD;lFlGFaCw~=7c5Lp%c$148l0;|7 zQ5=EEoo6TjZoM@$)j$5oi)`g+K8va%lb{wm;{^Dr{PK~s;p?;WXE}>`3j5o0xl(hf zvOcA{^-?Nncd%I~ZIDbFz}YjYSlhc~LR>Xm6a#I^7!|cr5Cu{aadKJhF1iF-m9n2X z@RCif=%YI~H>0NWBjI;iy0vwJO8E=S(euLMX~cDvnGk~lSDvf$o26UUM(N1dFJ11e z6vY$dQ~h)a$ie{%AS)J{^O$PXSZ91szVV}gUpS0H21E!RL~lYD z%C7*f8 zfK2OZfuNeXKP@M_-&6j4>birA5*6N3*3_+DE^Kx>NOnOXr*a{^r3~q|Gk;)c-)j%x zYwVsR?6tMFNKj}H3BRX`o_N%NqaZ*QAtlcSwUl*pPgiR(57C49X66M)v=($pX1tAr})fdsh+;HYT{Qe>MN>0Pxp= zimREOIteoqCnF~(I|nlt+*c7JM@cg)3rklLc6Me)b^tRI@T8Qhk*$@9kiCVi840k} zUyQ%7u!ARw4g-LRg^?M+M#8`f;A8{<0PO4}%p6QijLckYEF{bT4rWGHPF5C9pbYY_ zMnKBk3_!y2H-h-zkmA1twErbJ;_q$#zwQDX{xu-~=9~Rb-TuFsMF3R3GBUGsZ~^gt zVE;M(8fBnq^)HJo2O}p7aJ2M*X#)Rt$O@Fj{<9UZdqD9lBNGz`Co|B-3Y4+3Gcy4j zasg8S{&|re&^}9|!^+CS$jQpV&i?mAf`2_^C*c5UZT}IG1ei#m!|{(JJ(9l^um21J z4EWCr^?)}FyfO$rG&Pa*+o?#|8;CJfv?>B%Hn_hbv#4qbQqtmf?F4|O4-QoEX!tmv` z9GGQo)!_sG0cCfR`J?CBRugu&twEf!>8J9nN?;$bhGTN5%N?j42c*EmOcjxRquI}y z)jgio`x!R6cG=Vyr;l(V-M!>8nua1HQlF~P)oN(QMmvqAJz?zQZ8o}3b>CSs$GF<@ zQ6x556SF~$ddPH18nek1-noa=pqfuVjpPmcxZl;hjJp-QNy<|gG*pv`OORFp!au0U zT}UP)<0Zw(#JG}#qA`^*z7Vqh@ZwUCF-rub^2vlX)u?@3ypES**cXip3Ygsd5p-8@ zWwo=cw5-Hw7+KCuHGwmvwibOektR^`aV0lvwoV$q=gIgcU~YD^69Vta;mAK_$_<@x z!Gd(~qm@_SL%v$-zdkkq|IKFl=eGDSy4U}-iT>9q1Aj&PKT`(&S{VPfzyHN2{m+yE zCZLiSxW%n(UCo?H{xV;?nu(g3IGCEjef|42By7w8&VOAwt=c}e1Y=&GS6eaI5VXXF!91Tzz1IV>vb!N#bY<%K>>8Q0@QCR zYB3|B;B(&tb6^)SvRy&G!0Y&_!T-$wwD8&fiNIDoGf?K1aE{QpaTGqx6AKYSycWvsj!W@ zkdMf7dEHF??`$pLuZcfSX6L4Re_!b}fE^Np`hs+|No@r}S`qSnX=f^r_@G(+kaeL-4MAve@4Tf`^&?&d;EFPfB&<} z(f4+Q97o4IEcaW?vC&d{AcVz}$Y4MN6lSgYJTQpUF(NXRH9@5#8qybw12nP*c~~C7 zF_8yFsG}gMKNNHT`A#G-W@Y!vv!H%PR>Levf7zz@a{G7H@5I3aEjPI?;{5_^R$~$m z>R?%(-Oo2%J5R=@lZspsA8q{n4F2mc=J(OFCBMS$)jlH!tsC*Y*B&bO$3`>n*bZ#U zofdDhLyol`3RVl+K_K(#HNuF%e6_CKA|=VfQFI$YVJr+H;JH{i9y=D620~W%1z~3r z_ZwKa7J_U=5&~t1MJtEE`ATcSx_R%nYz;L*6@0i*)+*JxTkr8)ErVi7bQ_1#{1Cla zzkmsd-a4Ay;c=!W2@&{n;(!Z5!Ad#~i{n-^2_iGsfUrr82mextbVGl(vs(YW341Ww zE3rJxskvFbxG@a%B_cyKS&X|1|7P%JxeHB+l>IVti2k_Z z_T_Y0PvWZ=L>gXR{C>Vf7zFBmerVE<>m;LK1yE-n3{9k1znlZCQB387iq(K16C*Nwpv=pdK<|Guu=evMyzKSWQ$OfieN`=9c6BpN#X#l+9aBv+ z0iI3|DFafRL4SR#ByHbo@5O4_#e`EU6|M%SB_SM6LcN#hemD}-4@CguLcA$2E^)@s zgJCbo^sRgm6S84tUU|0tn;?u~e3DlC(4}-0L$kyUfiIGT$Y50uK_r-M;i}U#DAc9( zbzkImpy$2E=CNQ5lu-McAcwA@Lit`K*1A!R4CI3{zFdBWq9F0Y3=KjHbGEPzDa#8* zk`mOD@scwblMQLGgGMV5+>b+zTXhJg=1V?e3GPBPt5Y$7uLZeq##vo{=bGLJ%$i_P zIifuzE(FuG2#e!;&&RPJor;Y7x5&K!ee-*h4vqn9_qLeCsS$N_>%Uc8~WxuaI&nL}K;g9M8v zSS%fZV5YXKl@}M?1KO$lArBqj%^3rs6C|9H&p2m1t0>WJS(ja)EV4qaR7`QOvOifbIs;ue;|qO z7cm5L$==DxH1kD^tWJNq(_hnjX(LKyUz)Pbx5`~?Qloe;pn4@eN4_3ACzNj@)>ek-+GE2Y{O|dM~zQ%~%4mW4+PKF6t=JWxY3wAvQn7 z!$-E}ZRshstpU@NnFy48CV&mQ^7qDHQ;i@!okg8e;p?X5&C0m!&A8?v>jS2lxkwM- zlY5|ayTDdh+6b2iGB=HyhRoTBvZ&3scL0R*DY&)Q@MB8cp0IWtPdO;$9ZTkFfM`V- z|K%TmZQ!;yF)lBIxLSD1$zTq>c$;R7&LOd4IHBVjUUuAwb@N88IDQ$vXbvKK%Cf1+ zP_I3mp)U$9lAt3iD}Kz3eefjS!sY!7WEPJM0q*pWLI zsX1lhmOIx``_jBTQVx#0t??wRgwW!5m$$SmsbqR($*@c=oZ^>%s5hxr6UI4scm4m$pFQ;jrI~C{_XmN>lhAwPH)J-@kg( z`j!>jF^AkgY+@1J!9wHUwq3tIjIXcc#f90G|ge0P;Rla1}13?z@bzkGW&Dd70sM<`fN`nX5 z6t-g46_pFpQINkNRJ7ezgco}W-CD@L}2W|E$X_^?d( zUaPQLBQt4gdBoPw*p@k0>B41HA@%jznWY~2md3$;J{EzYbTuHvbs^$h8vws3XK8(V zeH-_poc0ab&8?)nO3pkwwYHKx#^hY*06LwRvn}LdRCo7;!F&Tx^>2j^WZWC} zCrZT(wEvac{dQJgZzn+xtUpdtO z9K3XM*&HEj_>XvT9!&n)y6R4A@Qd35En-T#N#<$%5_y6lDOR1>+Xp74xTmFVsJ&x9 zpkO%E%@jni%{T~#9?VpPN%=Ssy3J%nKsh}WI>od^Qf_M+EBl+8w&eeQpnA7 z!41vU7%W^>Cqv&Z5w&_iA|)T&j+{Ij4<^;ex7yu*7}thOq5dc6g46RYDZOnhpQv-v zPk{ebv+TxjT|)duy(_=xz3Q&Ugv-&EPvsD)mRduLsAy4Vjh|_Y>$FKN-PoEtv$dB= z$jfY+0XAj zzmSybr$$Gn`06jp?`WR0=+x|js`aJ`lcs1txHftEE|(GV*>U|;U(Q{%v3~q$tCSjt z=zyR)gSwe=Vb;9mzvS}fDZ6c6_(DaM4|FK6_(`pN&!nKhrpn~p4Fy*Xj=f4U)z3Wh zN00rLjP{_YWi=m9Or6!#b;qWqBA@G`4wx5tJReeh!PDxi(Z$b)3`v}>m6B)K?|QJo z-q1wuU1HYj$w!wD@b$hj#M$c6q#3n`T(1mKd$(&!lV&NYW*&0yM*~myfe~|6t=w+L zlGbXo)U9{yyIX&MtBGx4ss2l^pZBG|>_0`ax+lo5f5ZpxRw#;aubRlzBhHDb!WCz9a^*WkXqaj%M9fsZ*6ZDzb)JUsqN&uC%f#|F8BC} z*3{Mr>ryyu-x%?%DXQz)mGIFOC;C#iVIW+29-lQY4w8=l=Kz3MxhyBpK;mN~? zWz8Y96~`1>jXfw|s6JEOQn_g34;u%&T)kwGpK6hxX_2365zybIJmfpBwpF=k%AoJ> zCp|4YUukUbVjlf4xZmT$cB2*wkzw-qdPwj)YN;EwDab~EgF48p4Dy~{LRwE@ip*w zimzE3pWinxYqe(U&Vj<&yPAvblqa6;xPN%Ja^?Wj^u6AXM`}kh=08j_^VCjDRQiy+ ziqspgP;HTvol;eJW93!#P37YfSEebahOJZ3@l3kEDPe-j%Mq^(O;zgOc?FOfdcRvX z#G$ z^{!tVH=Vw={*!XqtGT*=JYLpp>Yor@Ve+`jw0nMM!Ee)i=69_Mud-|@*ta5h=*j1v zgXF8{7?dm;yVK2ldaSA9*@K$XSCtqhZ~pmk)A=l2xh(Alhvm!cmY1cO=TH?DwtAPl zrs%yZ8WizPbLsJDMT>-3{e~UQw_I8t6+f%Ku!3|fF}d-w$inH+F0IVT(@L%@aei%_ zd}pBjuDQ;;rj86drh4n$rC6uvzEjmhclT9OC*=DdD%kmqs8vzi;u5@LMevSw!8`ne zdj=>nK~(K%^`>Djme|$gCHfoA54KLy4v}A=YivK(U453pd%EoHcYVP8HDVrklxoVU zO`qnO?u~*%y24jX%jb`*ih`epvAMX?op~MSMxc$UzN$^`1Gp#NF;HtEu4? zn=^9XmS242{mQd!M(VNZtYN=Ke(w@|S_g-mt{h`v5c>0Q zW6kWYzU2I2KD^g=oGh zSJHe|ODbqaTzl3_W3HSvfbJad>NhSq=FpK+A8w1zuNp5aPn@3-Tv(GjbGEhhhM`f0 zC67w&1Ivfm#UJV4e?fe5X=7=(BPg3InSq?e0Ed;xxM`$mNQO-Q4j(Lr3{Gj2x_C)M4^y)Lq5b>n53KCJa2E z=gK?9RWQbTg0|~Vn6=q5E9a8OPn+%?*kHHdc|JX_cB*Cdo45P=L0xI4AexjK34iV5jg zLsxIjJQD46=41Yxpw80v5;qt;L0Vgs}rhAhrK^Omvt*J%--spkeOkq z%SY|2{QAc6DxF(tQyS^ECp@pd$h3CIVbf{K`&xcg%sg0oE@o8Yrw{W7T)xz1G|j}I zGDGK8LUPph0hi9#S^M3NTP?&iAAg{CY)XwY@1=9JHOPOH(G$0I^3ghbQ?@mf7l)K2 zhVE&+tD>^zsprAUn@#@a+5;H7_R5b@!&@GP@AdPr-N2d89+#`;a%Sz?<=O}D_&jyB zd#8Saot64sxp}3=q(wVs_17)SXzwoRxwvLd&sfjWbvesa`WslSc)ac4NX;Up7ZU@& z-qHkoIbkU%}aY=Y2X=~zMuMgWt&ns;z_YzyiE3Yv6R0v z2mi?=*tl$1rDbgO|F5_bBuzx|(k~vz`g=bz@*$sJ)%voy(m(9(ulVo(O>re@LImE^ zlfJR{-%?7_5>nE_NzzyG{`0gRlu-IJ?eV`_Ajw%v;G{3y{r6ovcysT|JchJH)t}j? ze+oxa1odS!+BxhV)Im}#YTYV7rfNp`v{UO<%$gcQ`R0(K&OV{)!`y$Em@x3k4(-)3 z88fuIT?2YFq9QsH*~c0;*S(e2I>gY=uJ zZ1?qcjhz9tv2Lxy%3~gu)}3tQ&fIsW3R;|4|Dvd<%o;wfO>433&cc&z?TMKwulE;x z-#lNRUfR{*X&9A%@ZJsW>$}D^S=h+CRn7LkFe0?UY>@4=r49}b1l_dkn{f_hw^JVN zX%!k!oc`xH>uxd#Olak1;Sz~hf)x*nP_{X<>?HFOt z4X#}IcH8OQD`V`AygZ;e$1mZ|(R#;>6Sf~aUcB1dvLc*&asR`mBBFTn{>s|z4}8+j ztu--?NjNbNGwtW{Su%2de~~FlQ~U#M|Btg3pUar_ewvQ`T%qi5df4K%YPCG%;W$Db zGGTxI;H5@}VzgFk{RJZ-$szq|efKAfW+glf)eA-t6bA_s5sZc>AH^_^#_a?KN-g#7 zOH#CWUZ}hTNk(xy4tPxjlMvGPI(v^NA>i#tF^r@DO`>*C;YkdmBzO+`^CPIe`a;$j zc)s^|JjD}eeGCot&P45aR$2(F*LVU;L(a4}40a|>dwtmslG>ag!ZS(ov>4AMMQ{Sz z8)QDkxT0B(5YJ0WB5aM|$kbz=cQ8bBh%8`T!_XGu1>cnSBL!KMq znTDxFd!uOr-bn4eU!VeMy|P|7pkX*Mj3YT(ybo4@`wplt#Us4%ko?B&5T0qCp~Q2* z#Rus{2hsXi8FNUVfEBmn;fYo;O!}6r7$!jZKLjJu-UJe)m#CdUNUJ(Ze}Bm^Nbn=t zr3G60f@rUHG`!R)V-5k~K)~{f7B~souONLDS3C!eWD`JW@qS^=5`-6l=fv$`UTJ|w z(LP{vA{Ya8>M#t52(`m}&H(9(@xl<&=XQI~3(`PZ7_-+}7`T0b+wl@S9^5Yx_a#`w zZy17=ej1Z8FUepv%zSNDj$+hGY<} zF(i#-7(=p1H-gD0y)z`*2M@PtL@?<+Cj=vqd}b(;K=_k@a6)}0NY^qFlEZx|s52^F z3rH*k!}+co}QK{J}^daW0;Nk|2EqctN%gL(xc%GZe*$c^~X5 zOn;P&^QC#rA2bEdga`)|7zhZ)BmE0piF6)A@tD>?A0axW1gH%wnwJKX1HmM4R|~a6 zI)I@8gQy)C4G6{}I}G|_-lBO-yP)LJS{MrB1q274SHR~BOo-$pBf?JdzjZAu z0|zWvqo^H?^ge6@?pKP(gNv64#*#>`u^h6c7_iq6uE5+yb8s?rBZw=sH!wTVxp5Sx zQw{`&c+DIwV>}l58VmskR^De=5AY&}MhAU}!$ z+YIrHAOn8__LCV{9->LGCoxQhohZZp1FH$~2TRDXa|A&eW9-GhV6`K=lOJjh0Zc6cq4&zIMG(*bN3 z(S`&9D`I+)0J0EF!Xq4jh{b(rq*EZ=gnUyL+%kk`FhS596pQ2^L?duJ3HAe63PL90 zeSjH()(oaChGAL4QXnbOc9XH6nKBA9bB|WFb>li+^Uz+7t6_lASD|Uaa?7aMo~N8N5aF z_}st&MtvFVBeD#98X`U;aMmykoCws8L-ra(XOUe8Fchz2!Hq(A22q0OR7Q-9<-i#d zuLYP8(GUngbiN><#r6wOFSNg zZqPm;K8e~vfDP?~!!an9gQzN+gF?0=%Yha^?ZBHvFd6cXlOg{gj%Ji^da3%pafv&{^^V zis7;F<_R8;#pPHew6j;n#Ai~7$I53CAGz8>~ZXYC0$-sa9m>OZW#aRIvZD zV;Ad2fbn7(22LIr6218dV1oEONRopji>Mt$^~CxEoFkMc0T{Abfr3!Z24I3%Zh*ms zWCXwju^uET*sh4K07i-92LR)~#Iv`T3w)XBCI8Jg#tYp2R(;NW8rykzctge>#Iy91 k%UR#8_RxZ@fBvp(ledeP_vbt*2|+Z5me + + + + + 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:

+
    +
  • OpenAI API (api.openai.com) — per il riconoscimento ottico degli spartiti
  • +
  • Anthropic API (api.anthropic.com) — per il riconoscimento ottico degli 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:

+
    +
  • Dati di identificazione personale (nome, email, telefono)
  • +
  • Dati di localizzazione
  • +
  • Identificatori del dispositivo per scopi di tracciamento
  • +
  • Dati di utilizzo per analytics o pubblicita
  • +
+

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 0000000000000000000000000000000000000000..c5387deb47a3fa61812749dbdc21e111b7525604 GIT binary patch literal 4215 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 Hu0mjfGSRWj literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f6d3833104c73258240aaa5bd6d66f89b78e2fa3 GIT binary patch literal 6497 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 Hu0mjfGSRWj literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..416a54cc9e3c7e42a3b0f4b7d2e917ba1acf763e GIT binary patch literal 12321 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 Hu0mjf`QsVT literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..416a54cc9e3c7e42a3b0f4b7d2e917ba1acf763e GIT binary patch literal 12321 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 Hu0mjf`QsVT literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..19e2940eafd5dca23ed66b9b39bac1c81d85e6ab GIT binary patch literal 18425 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..19e2940eafd5dca23ed66b9b39bac1c81d85e6ab GIT binary patch literal 18425 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 literal 0 HcmV?d00001 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 @@ + + + + + +
+ + + + + + + +
+
+ 🇮🇹 + 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"; + +