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 @Composable
actual fun rememberFilePickerLauncher( actual fun rememberFilePickerLauncher(
mimeTypes: List<String>, mimeTypes: List<String>,
onFileContent: (String) -> Unit, onFileContent: (fileName: String, content: String) -> Unit,
): () -> Unit { ): () -> Unit {
val context = LocalContext.current val context = LocalContext.current
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
@@ -22,7 +22,7 @@ actual fun rememberFilePickerLauncher(
val bytes = stream.readBytes() val bytes = stream.readBytes()
val content = extractMusicXmlFromBytes(bytes) val content = extractMusicXmlFromBytes(bytes)
if (content != null) { if (content != null) {
onFileContent(content) onFileContent(displayNameOf(context, it), content)
} }
} }
} catch (_: Exception) { } catch (_: Exception) {
@@ -33,3 +33,14 @@ actual fun rememberFilePickerLauncher(
return { launcher.launch(mimeTypes.toTypedArray()) } 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_website">djluza.com</string>
<string name="info_privacy_policy">Privacy Policy</string> <string name="info_privacy_policy">Privacy Policy</string>
<string name="info_privacy_url">https://djluza.com/intono/privacy</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> </resources>
@@ -234,4 +234,6 @@
<string name="info_website">djluza.com</string> <string name="info_website">djluza.com</string>
<string name="info_privacy_policy">Privacy Policy</string> <string name="info_privacy_policy">Privacy Policy</string>
<string name="info_privacy_url">https://djluza.com/intono/privacy</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> </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. * Platform-specific file picker launcher.
* Returns a lambda that, when invoked, opens the file picker. * 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 @Composable
expect fun rememberFilePickerLauncher( expect fun rememberFilePickerLauncher(
mimeTypes: List<String>, mimeTypes: List<String>,
onFileContent: (String) -> Unit, onFileContent: (fileName: String, content: String) -> Unit,
): () -> Unit ): () -> Unit
@@ -27,8 +27,16 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight 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 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.Exercise
import com.notemusicali.exercises.ExerciseRepository 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.Res
import notemusicali.shared.generated.resources.* import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -46,6 +54,8 @@ fun ExercisesScreen(
onBack: () -> Unit, onBack: () -> Unit,
onExerciseSelected: (NoteSequence) -> Unit, onExerciseSelected: (NoteSequence) -> Unit,
) { ) {
var imported by remember { mutableStateOf(ImportedExerciseRepository.getAll()) }
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter, contentAlignment = Alignment.TopCenter,
@@ -70,6 +80,28 @@ fun ExercisesScreen(
onClick = { onExerciseSelected(exercise.sequence) }, 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.notemusicali.exercises.ImportedExerciseRepository
import com.notemusicali.music.MusicXmlParser import com.notemusicali.music.MusicXmlParser
import com.notemusicali.music.NoteSequence import com.notemusicali.music.NoteSequence
import com.notemusicali.ui.components.BackTopBar import com.notemusicali.ui.components.BackTopBar
@@ -47,10 +48,17 @@ fun PracticeSetupScreen(
) { ) {
val filePickerLauncher = rememberFilePickerLauncher( val filePickerLauncher = rememberFilePickerLauncher(
mimeTypes = listOf("text/xml", "application/xml", "*/*"), mimeTypes = listOf("text/xml", "application/xml", "*/*"),
onFileContent = { content -> onFileContent = { fileName, content ->
try { 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()) { if (sequence.notes.isNotEmpty()) {
// Lo spartito importato resta disponibile tra gli esercizi
ImportedExerciseRepository.add(title, sequence)
onMusicXmlLoaded(sequence) onMusicXmlLoaded(sequence)
} }
} catch (_: Exception) { } 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 com.notemusicali.scores.extractMusicXmlFromBytes
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import java.awt.KeyboardFocusManager
import javax.swing.JFileChooser import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter import javax.swing.filechooser.FileNameExtensionFilter
@Composable @Composable
actual fun rememberFilePickerLauncher( actual fun rememberFilePickerLauncher(
mimeTypes: List<String>, mimeTypes: List<String>,
onFileContent: (String) -> Unit, onFileContent: (fileName: String, content: String) -> Unit,
): () -> Unit { ): () -> Unit {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
return { 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 { val chooser = JFileChooser().apply {
fileFilter = FileNameExtensionFilter("MusicXML files", "xml", "musicxml", "mxl") fileFilter = FileNameExtensionFilter("MusicXML files", "xml", "musicxml", "mxl")
dialogTitle = "Seleziona file MusicXML" dialogTitle = "Seleziona file MusicXML"
} }
val result = chooser.showOpenDialog(null) val parent = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow
val result = chooser.showOpenDialog(parent)
if (result == JFileChooser.APPROVE_OPTION) { if (result == JFileChooser.APPROVE_OPTION) {
try { val file = chooser.selectedFile
val bytes = chooser.selectedFile.readBytes() // Lettura e unzip fuori dall'EDT
val content = extractMusicXmlFromBytes(bytes) val content = withContext(Dispatchers.Default) {
if (content != null) { try {
onFileContent(content) 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 package com.notemusicali.ui.components
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import java.awt.KeyboardFocusManager
import javax.swing.JFileChooser import javax.swing.JFileChooser
@Composable @Composable
@@ -12,7 +13,9 @@ actual fun rememberFolderPickerLauncher(
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
dialogTitle = "Scegli cartella spartiti" 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 val file = chooser.selectedFile
onFolderSelected(file.absolutePath, file.name) onFolderSelected(file.absolutePath, file.name)
} }
@@ -18,6 +18,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.notemusicali.scan.PlatformImage import com.notemusicali.scan.PlatformImage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import java.awt.KeyboardFocusManager
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.imageio.ImageIO import javax.imageio.ImageIO
@@ -45,9 +47,17 @@ actual fun CameraCapture(
) )
Button( Button(
onClick = { onClick = {
scope.launch(Dispatchers.Default) { // Dialogo sull'EDT (Swing), decodifica dell'immagine su Default
val image = pickImageFile() ?: return@launch scope.launch(Dispatchers.Swing) {
withContext(Dispatchers.Main) { onImageCaptured(image) } val file = pickImageFile() ?: return@launch
val image = withContext(Dispatchers.Default) {
try {
ImageIO.read(file)
} catch (_: Exception) {
null
}
} ?: return@launch
onImageCaptured(image)
} }
}, },
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
@@ -61,16 +71,13 @@ actual fun CameraCapture(
} }
} }
private fun pickImageFile(): PlatformImage? { private fun pickImageFile(): java.io.File? {
val chooser = JFileChooser().apply { val chooser = JFileChooser().apply {
dialogTitle = "Scegli l'immagine dello spartito" dialogTitle = "Scegli l'immagine dello spartito"
fileFilter = FileNameExtensionFilter("Immagini (PNG, JPEG)", "png", "jpg", "jpeg") fileFilter = FileNameExtensionFilter("Immagini (PNG, JPEG)", "png", "jpg", "jpeg")
isMultiSelectionEnabled = false isMultiSelectionEnabled = false
} }
if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) return null val parent = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow
return try { if (chooser.showOpenDialog(parent) != JFileChooser.APPROVE_OPTION) return null
ImageIO.read(chooser.selectedFile) return chooser.selectedFile
} catch (_: Exception) {
null
}
} }
@@ -22,14 +22,14 @@ private var retainedFileDelegate: FilePickerDelegate? = null
@Composable @Composable
actual fun rememberFilePickerLauncher( actual fun rememberFilePickerLauncher(
mimeTypes: List<String>, mimeTypes: List<String>,
onFileContent: (String) -> Unit, onFileContent: (fileName: String, content: String) -> Unit,
): () -> Unit { ): () -> Unit {
return { return {
presentFilePicker(onFileContent) presentFilePicker(onFileContent)
} }
} }
private fun presentFilePicker(onFileContent: (String) -> Unit) { private fun presentFilePicker(onFileContent: (fileName: String, content: String) -> Unit) {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val rootVC = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return val rootVC = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return
val topVC = topPresentedViewController(rootVC) val topVC = topPresentedViewController(rootVC)
@@ -56,7 +56,7 @@ private fun topPresentedViewController(vc: UIViewController): UIViewController {
} }
private class FilePickerDelegate( private class FilePickerDelegate(
private val onFileContent: (String) -> Unit, private val onFileContent: (fileName: String, content: String) -> Unit,
) : NSObject(), UIDocumentPickerDelegateProtocol { ) : NSObject(), UIDocumentPickerDelegateProtocol {
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
@@ -71,7 +71,7 @@ private class FilePickerDelegate(
val data = NSData.dataWithContentsOfURL(url) ?: return val data = NSData.dataWithContentsOfURL(url) ?: return
val bytes = data.toByteArray() val bytes = data.toByteArray()
val content = extractMusicXmlFromBytes(bytes) ?: return val content = extractMusicXmlFromBytes(bytes) ?: return
onFileContent(content) onFileContent(url.lastPathComponent ?: "Import", content)
} finally { } finally {
if (accessing) url.stopAccessingSecurityScopedResource() if (accessing) url.stopAccessingSecurityScopedResource()
} }