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:
1 parent
f48a327a33
commit
3a95a3d433
12 files changed
+292
-30
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) {
|
||||
val file = chooser.selectedFile
|
||||
// Lettura e unzip fuori dall'EDT
|
||||
val content = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val bytes = chooser.selectedFile.readBytes()
|
||||
val content = extractMusicXmlFromBytes(bytes)
|
||||
if (content != null) {
|
||||
onFileContent(content)
|
||||
}
|
||||
extractMusicXmlFromBytes(file.readBytes())
|
||||
} catch (_: Exception) {
|
||||
// File read failed
|
||||
null
|
||||
}
|
||||
}
|
||||
// Callback di nuovo sull'EDT: aggiorna stato Compose e naviga
|
||||
if (content != null) {
|
||||
onFileContent(file.name, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user