feat: gli spartiti importati restano tra gli esercizi, eliminabili

- 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 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-07-22 21:51:45 +02:00
1 parent f48a327a33
commit 3a95a3d433
12 files changed
+293 -31

No files matched your search

@@ -10,7 +10,7 @@ import com.notemusicali.scores.extractMusicXmlFromBytes
@Composable
actual fun rememberFilePickerLauncher(
mimeTypes: List<String>,
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"
}
}
@@ -234,4 +234,6 @@
<string name="info_website">djluza.com</string>
<string name="info_privacy_policy">Privacy Policy</string>
<string name="info_privacy_url">https://djluza.com/intono/privacy</string>
<string name="imported_exercises">Imported</string>
<string name="delete_imported">Delete imported score</string>
</resources>
@@ -234,4 +234,6 @@
<string name="info_website">djluza.com</string>
<string name="info_privacy_policy">Privacy Policy</string>
<string name="info_privacy_url">https://djluza.com/intono/privacy</string>
<string name="imported_exercises">Importati</string>
<string name="delete_imported">Elimina spartito importato</string>
</resources>
@@ -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<StoredNote>,
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<ImportedExercise> {
val raw = settings.getStringOrNull(KEY) ?: return emptyList()
return try {
json.decodeFromString<List<ImportedExercise>>(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<ImportedExercise> = store.getAll()
fun add(title: String, sequence: NoteSequence): ImportedExercise = store.add(title, sequence)
fun remove(id: String) = store.remove(id)
}
@@ -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<String>,
onFileContent: (String) -> Unit,
onFileContent: (fileName: String, content: String) -> Unit,
): () -> Unit
@@ -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),
)
}
}
@@ -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) {
@@ -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())
}
}
@@ -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<String>,
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)
}
}
}
@@ -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)
}
@@ -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
}
@@ -22,14 +22,14 @@ private var retainedFileDelegate: FilePickerDelegate? = null
@Composable
actual fun rememberFilePickerLauncher(
mimeTypes: List<String>,
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()
}