From 3a95a3d4330f6e3f0d97e2788cb8e428256489f8 Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 22 Jul 2026 21:51:45 +0200 Subject: [PATCH] feat: gli spartiti importati restano tra gli esercizi, eliminabili MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImportedExerciseRepository: persistenza JSON degli import (titolo dal nome file, note con durate/bemolli, metro; max 50, più recenti primi) - FilePicker passa anche il nome del file (expect + 3 actual) - PracticeSetupScreen salva la sequenza importata prima di avviare - ExercisesScreen: sezione 'Importati' con anteprima e cestino - fix: picker desktop su Dispatchers.Swing con finestra parent (l'import XML congelava l'app: dialogo Swing su thread di background) Co-Authored-By: Claude Fable 5 --- .../ui/components/FilePicker.android.kt | 15 +++- .../composeResources/values-en/strings.xml | 2 + .../composeResources/values/strings.xml | 2 + .../exercises/ImportedExercises.kt | 80 +++++++++++++++++++ .../notemusicali/ui/components/FilePicker.kt | 4 +- .../ui/exercises/ExercisesScreen.kt | 78 ++++++++++++++++++ .../ui/practice/PracticeSetupScreen.kt | 12 ++- .../exercises/ImportedExerciseStoreTest.kt | 61 ++++++++++++++ .../ui/components/FilePicker.desktop.kt | 30 ++++--- .../ui/components/FolderPicker.desktop.kt | 5 +- .../ui/scan/CameraCapture.desktop.kt | 27 ++++--- .../ui/components/FilePicker.ios.kt | 8 +- 12 files changed, 293 insertions(+), 31 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/notemusicali/exercises/ImportedExercises.kt create mode 100644 shared/src/commonTest/kotlin/com/notemusicali/exercises/ImportedExerciseStoreTest.kt diff --git a/shared/src/androidMain/kotlin/com/notemusicali/ui/components/FilePicker.android.kt b/shared/src/androidMain/kotlin/com/notemusicali/ui/components/FilePicker.android.kt index 57e3675..cb456f2 100644 --- a/shared/src/androidMain/kotlin/com/notemusicali/ui/components/FilePicker.android.kt +++ b/shared/src/androidMain/kotlin/com/notemusicali/ui/components/FilePicker.android.kt @@ -10,7 +10,7 @@ import com.notemusicali.scores.extractMusicXmlFromBytes @Composable actual fun rememberFilePickerLauncher( mimeTypes: List, - onFileContent: (String) -> Unit, + onFileContent: (fileName: String, content: String) -> Unit, ): () -> Unit { val context = LocalContext.current val launcher = rememberLauncherForActivityResult( @@ -22,7 +22,7 @@ actual fun rememberFilePickerLauncher( val bytes = stream.readBytes() val content = extractMusicXmlFromBytes(bytes) if (content != null) { - onFileContent(content) + onFileContent(displayNameOf(context, it), content) } } } catch (_: Exception) { @@ -33,3 +33,14 @@ actual fun rememberFilePickerLauncher( return { launcher.launch(mimeTypes.toTypedArray()) } } + +private fun displayNameOf(context: android.content.Context, uri: Uri): String { + return try { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null + } ?: (uri.lastPathSegment ?: "Import") + } catch (_: Exception) { + uri.lastPathSegment ?: "Import" + } +} diff --git a/shared/src/commonMain/composeResources/values-en/strings.xml b/shared/src/commonMain/composeResources/values-en/strings.xml index 86c72f8..83819ac 100644 --- a/shared/src/commonMain/composeResources/values-en/strings.xml +++ b/shared/src/commonMain/composeResources/values-en/strings.xml @@ -234,4 +234,6 @@ djluza.com Privacy Policy https://djluza.com/intono/privacy + Imported + Delete imported score diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 3a7dd1d..8fda0f5 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -234,4 +234,6 @@ djluza.com Privacy Policy https://djluza.com/intono/privacy + Importati + Elimina spartito importato diff --git a/shared/src/commonMain/kotlin/com/notemusicali/exercises/ImportedExercises.kt b/shared/src/commonMain/kotlin/com/notemusicali/exercises/ImportedExercises.kt new file mode 100644 index 0000000..3ede7ba --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/exercises/ImportedExercises.kt @@ -0,0 +1,80 @@ +package com.notemusicali.exercises + +import com.notemusicali.music.MusicalNote +import com.notemusicali.music.NoteDuration +import com.notemusicali.music.NoteSequence +import com.notemusicali.util.currentTimeMillis +import com.russhwolf.settings.Settings +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +data class StoredNote( + val midi: Int, + val duration: NoteDuration = NoteDuration.QUARTER, + val flat: Boolean = false, +) + +@Serializable +data class ImportedExercise( + val id: String, + val title: String, + val notes: List, + val beats: Int = 4, + val beatType: Int = 4, +) { + fun toNoteSequence(): NoteSequence = NoteSequence( + name = title, + notes = notes.map { MusicalNote.fromMidi(it.midi, it.duration, preferFlat = it.flat) }, + beats = beats, + beatType = beatType, + ) +} + +/** Persistenza degli spartiti importati dall'utente (JSON su multiplatform-settings). */ +internal class ImportedExerciseStore(private val settings: Settings) { + + private val json = Json { ignoreUnknownKeys = true } + + fun getAll(): List { + val raw = settings.getStringOrNull(KEY) ?: return emptyList() + return try { + json.decodeFromString>(raw) + } catch (_: Exception) { + emptyList() + } + } + + fun add(title: String, sequence: NoteSequence, nowMs: Long = currentTimeMillis()): ImportedExercise { + val exercise = ImportedExercise( + id = nowMs.toString(), + title = title, + notes = sequence.notes.map { StoredNote(it.midiNumber, it.duration, it.preferFlat) }, + beats = sequence.beats, + beatType = sequence.beatType, + ) + val updated = (listOf(exercise) + getAll()).take(MAX_IMPORTED) + settings.putString(KEY, json.encodeToString(updated)) + return exercise + } + + fun remove(id: String) { + val remaining = getAll().filterNot { it.id == id } + settings.putString(KEY, json.encodeToString(remaining)) + } + + private companion object { + const val KEY = "imported_exercises" + const val MAX_IMPORTED = 50 + } +} + +/** Facade con lo storage reale dell'app. */ +object ImportedExerciseRepository { + private val store by lazy { ImportedExerciseStore(Settings()) } + + fun getAll(): List = store.getAll() + fun add(title: String, sequence: NoteSequence): ImportedExercise = store.add(title, sequence) + fun remove(id: String) = store.remove(id) +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/FilePicker.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/FilePicker.kt index 616284f..047d8e4 100644 --- a/shared/src/commonMain/kotlin/com/notemusicali/ui/components/FilePicker.kt +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/components/FilePicker.kt @@ -5,10 +5,10 @@ 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. + * [onFileContent] is called with the display name of the file and its content. */ @Composable expect fun rememberFilePickerLauncher( mimeTypes: List, - onFileContent: (String) -> Unit, + onFileContent: (fileName: String, content: String) -> Unit, ): () -> Unit diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/exercises/ExercisesScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/exercises/ExercisesScreen.kt index 76d70a9..5f2b16d 100644 --- a/shared/src/commonMain/kotlin/com/notemusicali/ui/exercises/ExercisesScreen.kt +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/exercises/ExercisesScreen.kt @@ -27,8 +27,16 @@ 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 androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.IconButton +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import com.notemusicali.exercises.Exercise import com.notemusicali.exercises.ExerciseRepository +import com.notemusicali.exercises.ImportedExercise +import com.notemusicali.exercises.ImportedExerciseRepository import notemusicali.shared.generated.resources.Res import notemusicali.shared.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -46,6 +54,8 @@ fun ExercisesScreen( onBack: () -> Unit, onExerciseSelected: (NoteSequence) -> Unit, ) { + var imported by remember { mutableStateOf(ImportedExerciseRepository.getAll()) } + Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter, @@ -70,6 +80,28 @@ fun ExercisesScreen( onClick = { onExerciseSelected(exercise.sequence) }, ) } + + if (imported.isNotEmpty()) { + item { + Text( + text = stringResource(Res.string.imported_exercises), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White.copy(alpha = 0.5f), + modifier = Modifier.padding(top = 8.dp), + ) + } + items(imported, key = { it.id }) { entry -> + ImportedExerciseCard( + entry = entry, + onClick = { onExerciseSelected(entry.toNoteSequence()) }, + onDelete = { + ImportedExerciseRepository.remove(entry.id) + imported = ImportedExerciseRepository.getAll() + }, + ) + } + } } } } @@ -133,3 +165,49 @@ private fun StarRating(level: Int) { } } } + +@Composable +private fun ImportedExerciseCard( + entry: ImportedExercise, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + GradientCard( + gradient = CardGradients.scoreFile, + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = entry.title, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(Res.string.delete_imported), + tint = Color.White.copy(alpha = 0.6f), + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + StaffPreview( + notes = entry.toNoteSequence().notes, + beats = entry.beats, + beatType = entry.beatType, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.n_notes, entry.notes.size), + fontSize = 12.sp, + color = Color.White.copy(alpha = 0.5f), + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeSetupScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeSetupScreen.kt index 3c47c31..4d96fa6 100644 --- a/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeSetupScreen.kt +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/practice/PracticeSetupScreen.kt @@ -28,6 +28,7 @@ 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.exercises.ImportedExerciseRepository import com.notemusicali.music.MusicXmlParser import com.notemusicali.music.NoteSequence import com.notemusicali.ui.components.BackTopBar @@ -47,10 +48,17 @@ fun PracticeSetupScreen( ) { val filePickerLauncher = rememberFilePickerLauncher( mimeTypes = listOf("text/xml", "application/xml", "*/*"), - onFileContent = { content -> + onFileContent = { fileName, content -> try { - val sequence = MusicXmlParser.parse(content, "MusicXML Import") + val title = fileName + .removeSuffix(".musicxml").removeSuffix(".music.xml") + .removeSuffix(".xml").removeSuffix(".mxl") + .replace('_', ' ') + .ifBlank { "MusicXML Import" } + val sequence = MusicXmlParser.parse(content, title) if (sequence.notes.isNotEmpty()) { + // Lo spartito importato resta disponibile tra gli esercizi + ImportedExerciseRepository.add(title, sequence) onMusicXmlLoaded(sequence) } } catch (_: Exception) { diff --git a/shared/src/commonTest/kotlin/com/notemusicali/exercises/ImportedExerciseStoreTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/exercises/ImportedExerciseStoreTest.kt new file mode 100644 index 0000000..220cee9 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/exercises/ImportedExerciseStoreTest.kt @@ -0,0 +1,61 @@ +package com.notemusicali.exercises + +import com.notemusicali.music.MusicalNote +import com.notemusicali.music.NoteDuration +import com.notemusicali.music.NoteSequence +import com.russhwolf.settings.MapSettings +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ImportedExerciseStoreTest { + + private fun sequence() = NoteSequence( + name = "Test", + notes = listOf( + MusicalNote.fromMidi(60, NoteDuration.EIGHTH), + MusicalNote.fromMidi(70, NoteDuration.HALF, preferFlat = true), + ), + beats = 3, + beatType = 4, + ) + + @Test + fun `added exercise round trips with notes durations flats and meter`() { + val store = ImportedExerciseStore(MapSettings()) + store.add("Il mio spartito", sequence(), nowMs = 1000L) + + val loaded = store.getAll().single() + assertEquals("Il mio spartito", loaded.title) + val seq = loaded.toNoteSequence() + assertEquals(2, seq.notes.size) + assertEquals(60, seq.notes[0].midiNumber) + assertEquals(NoteDuration.EIGHTH, seq.notes[0].duration) + assertEquals("SIb", seq.notes[1].displayName) + assertEquals(NoteDuration.HALF, seq.notes[1].duration) + assertEquals(3, seq.beats) + assertEquals(4, seq.beatType) + } + + @Test + fun `newest import comes first`() { + val store = ImportedExerciseStore(MapSettings()) + store.add("Primo", sequence(), nowMs = 1L) + store.add("Secondo", sequence(), nowMs = 2L) + assertEquals(listOf("Secondo", "Primo"), store.getAll().map { it.title }) + } + + @Test + fun `remove deletes only the requested exercise`() { + val store = ImportedExerciseStore(MapSettings()) + val a = store.add("A", sequence(), nowMs = 1L) + store.add("B", sequence(), nowMs = 2L) + store.remove(a.id) + assertEquals(listOf("B"), store.getAll().map { it.title }) + } + + @Test + fun `empty storage yields empty list`() { + assertTrue(ImportedExerciseStore(MapSettings()).getAll().isEmpty()) + } +} 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 index 49944a1..c2322b6 100644 --- a/shared/src/desktopMain/kotlin/com/notemusicali/ui/components/FilePicker.desktop.kt +++ b/shared/src/desktopMain/kotlin/com/notemusicali/ui/components/FilePicker.desktop.kt @@ -5,31 +5,41 @@ import androidx.compose.runtime.rememberCoroutineScope import com.notemusicali.scores.extractMusicXmlFromBytes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.swing.Swing +import kotlinx.coroutines.withContext +import java.awt.KeyboardFocusManager import javax.swing.JFileChooser import javax.swing.filechooser.FileNameExtensionFilter @Composable actual fun rememberFilePickerLauncher( mimeTypes: List, - onFileContent: (String) -> Unit, + onFileContent: (fileName: String, content: String) -> Unit, ): () -> Unit { val scope = rememberCoroutineScope() return { - scope.launch(Dispatchers.Default) { + // I dialoghi Swing vivono solo sull'Event Dispatch Thread; mostrarli da + // un thread di background inchioda l'app su macOS + scope.launch(Dispatchers.Swing) { val chooser = JFileChooser().apply { fileFilter = FileNameExtensionFilter("MusicXML files", "xml", "musicxml", "mxl") dialogTitle = "Seleziona file MusicXML" } - val result = chooser.showOpenDialog(null) + val parent = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow + val result = chooser.showOpenDialog(parent) if (result == JFileChooser.APPROVE_OPTION) { - try { - val bytes = chooser.selectedFile.readBytes() - val content = extractMusicXmlFromBytes(bytes) - if (content != null) { - onFileContent(content) + val file = chooser.selectedFile + // Lettura e unzip fuori dall'EDT + val content = withContext(Dispatchers.Default) { + try { + extractMusicXmlFromBytes(file.readBytes()) + } catch (_: Exception) { + null } - } catch (_: Exception) { - // File read failed + } + // Callback di nuovo sull'EDT: aggiorna stato Compose e naviga + if (content != null) { + onFileContent(file.name, content) } } } 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 index d47cf5d..2ad1613 100644 --- a/shared/src/desktopMain/kotlin/com/notemusicali/ui/components/FolderPicker.desktop.kt +++ b/shared/src/desktopMain/kotlin/com/notemusicali/ui/components/FolderPicker.desktop.kt @@ -1,6 +1,7 @@ package com.notemusicali.ui.components import androidx.compose.runtime.Composable +import java.awt.KeyboardFocusManager import javax.swing.JFileChooser @Composable @@ -12,7 +13,9 @@ actual fun rememberFolderPickerLauncher( fileSelectionMode = JFileChooser.DIRECTORIES_ONLY dialogTitle = "Scegli cartella spartiti" } - if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { + // Parent = finestra attiva, così il dialogo non finisce dietro l'app + val parent = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow + if (chooser.showOpenDialog(parent) == JFileChooser.APPROVE_OPTION) { val file = chooser.selectedFile onFolderSelected(file.absolutePath, file.name) } 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 index a750804..69a2b76 100644 --- a/shared/src/desktopMain/kotlin/com/notemusicali/ui/scan/CameraCapture.desktop.kt +++ b/shared/src/desktopMain/kotlin/com/notemusicali/ui/scan/CameraCapture.desktop.kt @@ -18,6 +18,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.notemusicali.scan.PlatformImage import kotlinx.coroutines.Dispatchers +import java.awt.KeyboardFocusManager +import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.imageio.ImageIO @@ -45,9 +47,17 @@ actual fun CameraCapture( ) Button( onClick = { - scope.launch(Dispatchers.Default) { - val image = pickImageFile() ?: return@launch - withContext(Dispatchers.Main) { onImageCaptured(image) } + // Dialogo sull'EDT (Swing), decodifica dell'immagine su Default + scope.launch(Dispatchers.Swing) { + val file = pickImageFile() ?: return@launch + val image = withContext(Dispatchers.Default) { + try { + ImageIO.read(file) + } catch (_: Exception) { + null + } + } ?: return@launch + onImageCaptured(image) } }, colors = ButtonDefaults.buttonColors( @@ -61,16 +71,13 @@ actual fun CameraCapture( } } -private fun pickImageFile(): PlatformImage? { +private fun pickImageFile(): java.io.File? { 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 - } + val parent = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow + if (chooser.showOpenDialog(parent) != JFileChooser.APPROVE_OPTION) return null + return chooser.selectedFile } 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 index 97d0e03..6938749 100644 --- a/shared/src/iosMain/kotlin/com/notemusicali/ui/components/FilePicker.ios.kt +++ b/shared/src/iosMain/kotlin/com/notemusicali/ui/components/FilePicker.ios.kt @@ -22,14 +22,14 @@ private var retainedFileDelegate: FilePickerDelegate? = null @Composable actual fun rememberFilePickerLauncher( mimeTypes: List, - onFileContent: (String) -> Unit, + onFileContent: (fileName: String, content: String) -> Unit, ): () -> Unit { return { presentFilePicker(onFileContent) } } -private fun presentFilePicker(onFileContent: (String) -> Unit) { +private fun presentFilePicker(onFileContent: (fileName: String, content: String) -> Unit) { @Suppress("DEPRECATION") val rootVC = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return val topVC = topPresentedViewController(rootVC) @@ -56,7 +56,7 @@ private fun topPresentedViewController(vc: UIViewController): UIViewController { } private class FilePickerDelegate( - private val onFileContent: (String) -> Unit, + private val onFileContent: (fileName: String, content: String) -> Unit, ) : NSObject(), UIDocumentPickerDelegateProtocol { @OptIn(ExperimentalForeignApi::class) @@ -71,7 +71,7 @@ private class FilePickerDelegate( val data = NSData.dataWithContentsOfURL(url) ?: return val bytes = data.toByteArray() val content = extractMusicXmlFromBytes(bytes) ?: return - onFileContent(content) + onFileContent(url.lastPathComponent ?: "Import", content) } finally { if (accessing) url.stopAccessingSecurityScopedResource() }