Compare commits

..
7 Commits
Author SHA1 Message Date
lucianoandClaude Fable 5 3a95a3d433 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>
2026-07-22 21:51:45 +02:00
lucianoandClaude Fable 5 f48a327a33 fix(ui): in modalità Spartito lo spartito assorbe lo spazio residuo
Gli Spacer elastici che centravano il contenuto collassano in FULL
(si dividevano lo spazio col pentagramma lasciandogli un terzo);
il viewport si aggancia a righi interi per non tagliare un rigo a metà.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 21:24:56 +02:00
lucianoandClaude Fable 5 860274aa41 ui: finestra desktop di default a 1268px di altezza
Altri +200px di base per l'area dello spartito in Pratica (che prende
lo spazio residuo con weight).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 21:20:57 +02:00
lucianoandClaude Fable 5 37f8b4645d ui: +300px di altezza di base per finestra desktop e spartito
Finestra desktop 1024x1068 (era 768) e fallback del viewport
spartito a 640dp (era 340).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 21:18:39 +02:00
lucianoandClaude Fable 5 88196f0f51 fix(ui): l'area dello spartito si adatta all'altezza della finestra
StaffFullView usa per intero l'altezza imposta dal contenitore (il tetto
di 340dp resta solo come fallback senza vincoli); in Pratica la vista
Spartito prende lo spazio verticale residuo con weight(1f, fill=false).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 21:15:39 +02:00
lucianoandClaude Fable 5 0a835699e0 fix(ui): spartito ad altezza fissa con auto-scroll; inserimento manuale segue l'ultima nota
- StaffFullView: viewport limitato (maxVisibleHeight, default 340dp) con
  scroll interno — in Pratica la card della nota rilevata resta sempre
  visibile anche con spartiti lunghi; l'auto-scroll alla riga corrente
  ora ha un viewport reale su cui agire
- ManualInputScreen: la riga delle note selezionate scorre da sola
  sull'ultima nota inserita

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 21:12:42 +02:00
lucianoandClaude Fable 5 d0d94c30fa feat: Studio op.84 n.10 di Dancla tra gli esercizi
177 note (Sol3-Re6, Sol maggiore, 4/4) estratte dal MusicXML con il
parser dell'app; livello 4, con test di presenza e sanità.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:50:20 +02:00
19 changed files with 552 additions and 39 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
{"sessionId":"db412f81-3e15-4e3f-9010-4a026dced776","pid":79175,"procStart":"Tue Jul 21 20:39:41 2026","acquiredAt":1784666538953}
@@ -15,7 +15,7 @@ fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "InTono",
state = rememberWindowState(width = 1024.dp, height = 768.dp),
state = rememberWindowState(width = 1024.dp, height = 1268.dp),
) {
NoteMusicaliTheme {
Surface(
@@ -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>
@@ -1,6 +1,7 @@
package com.notemusicali.exercises
import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteDuration
import com.notemusicali.music.NoteSequence
data class Exercise(
@@ -125,5 +126,195 @@ object ExerciseRepository {
),
),
),
Exercise(
id = 6,
title = "Studio op.84 n.10 — Dancla",
description = "Studio per violino in Sol maggiore",
level = 4,
sequence = NoteSequence(
name = "Studio op.84 n.10 — Dancla",
notes = listOf(
MusicalNote.fromMidi(55, NoteDuration.EIGHTH),
MusicalNote.fromMidi(59, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(67, NoteDuration.EIGHTH),
MusicalNote.fromMidi(71, NoteDuration.HALF),
MusicalNote.fromMidi(59, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(67, NoteDuration.EIGHTH),
MusicalNote.fromMidi(71, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.HALF),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(72, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.EIGHTH),
MusicalNote.fromMidi(76, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.HALF),
MusicalNote.fromMidi(67, NoteDuration.EIGHTH),
MusicalNote.fromMidi(71, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(79, NoteDuration.EIGHTH),
MusicalNote.fromMidi(83, NoteDuration.HALF),
MusicalNote.fromMidi(55, NoteDuration.EIGHTH),
MusicalNote.fromMidi(59, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(67, NoteDuration.EIGHTH),
MusicalNote.fromMidi(71, NoteDuration.HALF),
MusicalNote.fromMidi(59, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(67, NoteDuration.EIGHTH),
MusicalNote.fromMidi(71, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.HALF),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(72, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.EIGHTH),
MusicalNote.fromMidi(76, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.HALF),
MusicalNote.fromMidi(67, NoteDuration.EIGHTH),
MusicalNote.fromMidi(71, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(79, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.HALF),
MusicalNote.fromMidi(66, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(78, NoteDuration.EIGHTH),
MusicalNote.fromMidi(81, NoteDuration.HALF),
MusicalNote.fromMidi(81, NoteDuration.EIGHTH),
MusicalNote.fromMidi(79, NoteDuration.EIGHTH),
MusicalNote.fromMidi(76, NoteDuration.EIGHTH),
MusicalNote.fromMidi(73, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.HALF),
MusicalNote.fromMidi(64, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.EIGHTH),
MusicalNote.fromMidi(73, NoteDuration.EIGHTH),
MusicalNote.fromMidi(76, NoteDuration.EIGHTH),
MusicalNote.fromMidi(81, NoteDuration.HALF),
MusicalNote.fromMidi(81, NoteDuration.EIGHTH),
MusicalNote.fromMidi(78, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.EIGHTH),
MusicalNote.fromMidi(66, NoteDuration.HALF),
MusicalNote.fromMidi(67, NoteDuration.EIGHTH),
MusicalNote.fromMidi(71, NoteDuration.EIGHTH),
MusicalNote.fromMidi(76, NoteDuration.EIGHTH),
MusicalNote.fromMidi(79, NoteDuration.EIGHTH),
MusicalNote.fromMidi(83, NoteDuration.HALF),
MusicalNote.fromMidi(66, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(78, NoteDuration.EIGHTH),
MusicalNote.fromMidi(81, NoteDuration.HALF),
MusicalNote.fromMidi(81, NoteDuration.EIGHTH),
MusicalNote.fromMidi(79, NoteDuration.EIGHTH),
MusicalNote.fromMidi(76, NoteDuration.EIGHTH),
MusicalNote.fromMidi(73, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.HALF),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.EIGHTH),
MusicalNote.fromMidi(66, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.HALF),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(66, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.HALF),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(67, NoteDuration.EIGHTH),
MusicalNote.fromMidi(70, NoteDuration.EIGHTH, preferFlat = true),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.HALF),
MusicalNote.fromMidi(67, NoteDuration.EIGHTH),
MusicalNote.fromMidi(70, NoteDuration.EIGHTH, preferFlat = true),
MusicalNote.fromMidi(70, NoteDuration.EIGHTH, preferFlat = true),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(79, NoteDuration.EIGHTH),
MusicalNote.fromMidi(79, NoteDuration.EIGHTH),
MusicalNote.fromMidi(78, NoteDuration.EIGHTH),
MusicalNote.fromMidi(78, NoteDuration.EIGHTH),
MusicalNote.fromMidi(81, NoteDuration.EIGHTH),
MusicalNote.fromMidi(81, NoteDuration.EIGHTH),
MusicalNote.fromMidi(86, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.HALF),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.EIGHTH),
MusicalNote.fromMidi(66, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.HALF),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(70, NoteDuration.EIGHTH, preferFlat = true),
MusicalNote.fromMidi(67, NoteDuration.EIGHTH),
MusicalNote.fromMidi(70, NoteDuration.EIGHTH, preferFlat = true),
MusicalNote.fromMidi(62, NoteDuration.HALF),
MusicalNote.fromMidi(55, NoteDuration.EIGHTH),
MusicalNote.fromMidi(58, NoteDuration.EIGHTH, preferFlat = true),
MusicalNote.fromMidi(58, NoteDuration.EIGHTH, preferFlat = true),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(67, NoteDuration.EIGHTH),
MusicalNote.fromMidi(67, NoteDuration.EIGHTH),
MusicalNote.fromMidi(66, NoteDuration.EIGHTH),
MusicalNote.fromMidi(66, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(71, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(79, NoteDuration.EIGHTH),
MusicalNote.fromMidi(83, NoteDuration.EIGHTH),
MusicalNote.fromMidi(86, NoteDuration.HALF),
MusicalNote.fromMidi(86, NoteDuration.EIGHTH),
MusicalNote.fromMidi(84, NoteDuration.EIGHTH),
MusicalNote.fromMidi(81, NoteDuration.EIGHTH),
MusicalNote.fromMidi(78, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.HALF),
MusicalNote.fromMidi(69, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(78, NoteDuration.EIGHTH),
MusicalNote.fromMidi(81, NoteDuration.EIGHTH),
MusicalNote.fromMidi(86, NoteDuration.HALF),
MusicalNote.fromMidi(83, NoteDuration.EIGHTH),
MusicalNote.fromMidi(79, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.EIGHTH),
MusicalNote.fromMidi(71, NoteDuration.EIGHTH),
MusicalNote.fromMidi(67, NoteDuration.HALF),
MusicalNote.fromMidi(60, NoteDuration.EIGHTH),
MusicalNote.fromMidi(64, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.EIGHTH),
MusicalNote.fromMidi(72, NoteDuration.EIGHTH),
MusicalNote.fromMidi(76, NoteDuration.HALF),
MusicalNote.fromMidi(59, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(67, NoteDuration.EIGHTH),
MusicalNote.fromMidi(71, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.HALF),
MusicalNote.fromMidi(57, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(66, NoteDuration.EIGHTH),
MusicalNote.fromMidi(69, NoteDuration.EIGHTH),
MusicalNote.fromMidi(74, NoteDuration.HALF),
MusicalNote.fromMidi(67, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(59, NoteDuration.EIGHTH),
MusicalNote.fromMidi(62, NoteDuration.EIGHTH),
MusicalNote.fromMidi(55, NoteDuration.QUARTER),
),
beats = 4,
beatType = 4,
),
),
)
}
@@ -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
@@ -1,6 +1,7 @@
package com.notemusicali.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -13,6 +14,7 @@ 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 androidx.compose.ui.unit.dp
import com.notemusicali.music.MusicalNote
@@ -29,6 +31,8 @@ fun StaffFullView(
lightTheme: Boolean = false,
beats: Int = 4,
beatType: Int = 4,
// Altezza massima visibile: oltre, lo spartito scorre da solo seguendo la nota corrente
maxVisibleHeight: Dp = 640.dp,
) {
if (notes.isEmpty()) return
@@ -65,12 +69,25 @@ fun StaffFullView(
scrollState.animateScrollTo(targetY)
}
Canvas(
// Con un vincolo di altezza dal contenitore (es. weight in Pratica) il
// viewport si adatta alla finestra; altrimenti vale il tetto di default.
// L'altezza si aggancia a righi interi per non tagliare un rigo a metà.
val heightBound = if (this.maxHeight != Dp.Infinity) this.maxHeight else maxVisibleHeight
val stride = systemHeightDp + systemGapDp
val wholeSystems = ((heightBound + systemGapDp) / stride).toInt().coerceAtLeast(1)
val snappedBound = stride * wholeSystems - systemGapDp
val viewportHeightDp = if (totalHeightDp < snappedBound) totalHeightDp else snappedBound
Box(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
.height(totalHeightDp),
.height(viewportHeightDp)
.verticalScroll(scrollState),
) {
Canvas(
modifier = Modifier
.fillMaxWidth()
.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)
@@ -194,5 +211,6 @@ fun StaffFullView(
}
}
}
}
}
}
@@ -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),
)
}
}
@@ -21,6 +21,8 @@ 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.rememberLazyListState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
@@ -231,7 +233,13 @@ fun ManualInputScreen(
)
if (notes.isNotEmpty()) {
val notesRowState = rememberLazyListState()
LaunchedEffect(notes.size) {
// Segui sempre l'ultima nota inserita
notesRowState.animateScrollToItem(notes.size - 1)
}
LazyRow(
state = notesRowState,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
@@ -274,7 +274,11 @@ fun PracticeScreen(
)
}
Spacer(modifier = Modifier.weight(1f))
if (currentMode == StaffMode.FULL) {
Spacer(modifier = Modifier.height(8.dp))
} else {
Spacer(modifier = Modifier.weight(1f))
}
// Staff display based on mode
val staffBgMod = if (lightStaff) {
@@ -310,7 +314,8 @@ fun PracticeScreen(
StaffFullView(
notes = state.sequence.notes,
currentIndex = state.currentIndex,
modifier = staffBgMod,
// Lo spartito si prende lo spazio residuo: cresce con la finestra
modifier = Modifier.weight(1f, fill = false).then(staffBgMod),
lightTheme = lightStaff,
beats = state.sequence.beats,
beatType = state.sequence.beatType,
@@ -389,7 +394,11 @@ fun PracticeScreen(
}
}
Spacer(modifier = Modifier.weight(1f))
if (currentMode == StaffMode.FULL) {
Spacer(modifier = Modifier.height(8.dp))
} else {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
@@ -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,26 @@
package com.notemusicali.exercises
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class ExerciseRepositoryTest {
@Test
fun `dancla study op84 n10 is available as exercise`() {
val exercise = ExerciseRepository.all.firstOrNull { it.title.contains("Dancla") }
assertNotNull(exercise, "lo studio di Dancla deve essere tra gli esercizi")
assertTrue(exercise.sequence.notes.size > 100, "atteso lo studio completo, trovate ${exercise.sequence.notes.size} note")
assertEquals(4, exercise.sequence.beats)
assertEquals(4, exercise.sequence.beatType)
// Studio per violino: tutte le note dentro l'estensione del violino (G3..E7)
assertTrue(exercise.sequence.notes.all { it.midiNumber in 55..100 })
}
@Test
fun `exercise ids are unique`() {
val ids = ExerciseRepository.all.map { it.id }
assertEquals(ids.size, ids.toSet().size)
}
}
@@ -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()
}