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 547 additions and 34 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( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "InTono", title = "InTono",
state = rememberWindowState(width = 1024.dp, height = 768.dp), state = rememberWindowState(width = 1024.dp, height = 1268.dp),
) { ) {
NoteMusicaliTheme { NoteMusicaliTheme {
Surface( Surface(
@@ -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>
@@ -1,6 +1,7 @@
package com.notemusicali.exercises package com.notemusicali.exercises
import com.notemusicali.music.MusicalNote import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteDuration
import com.notemusicali.music.NoteSequence import com.notemusicali.music.NoteSequence
data class Exercise( 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. * 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
@@ -1,6 +1,7 @@
package com.notemusicali.ui.components package com.notemusicali.ui.components
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.graphics.Color
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.notemusicali.music.MusicalNote import com.notemusicali.music.MusicalNote
@@ -29,6 +31,8 @@ fun StaffFullView(
lightTheme: Boolean = false, lightTheme: Boolean = false,
beats: Int = 4, beats: Int = 4,
beatType: 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 if (notes.isEmpty()) return
@@ -65,10 +69,23 @@ fun StaffFullView(
scrollState.animateScrollTo(targetY) scrollState.animateScrollTo(targetY)
} }
// 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()
.height(viewportHeightDp)
.verticalScroll(scrollState),
) {
Canvas( Canvas(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(scrollState)
.height(totalHeightDp), .height(totalHeightDp),
) { ) {
val lineColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.4f) val lineColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.4f)
@@ -196,3 +213,4 @@ fun StaffFullView(
} }
} }
} }
}
@@ -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),
)
}
}
@@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn 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.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -231,7 +233,13 @@ fun ManualInputScreen(
) )
if (notes.isNotEmpty()) { if (notes.isNotEmpty()) {
val notesRowState = rememberLazyListState()
LaunchedEffect(notes.size) {
// Segui sempre l'ultima nota inserita
notesRowState.animateScrollToItem(notes.size - 1)
}
LazyRow( LazyRow(
state = notesRowState,
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -274,7 +274,11 @@ fun PracticeScreen(
) )
} }
if (currentMode == StaffMode.FULL) {
Spacer(modifier = Modifier.height(8.dp))
} else {
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
}
// Staff display based on mode // Staff display based on mode
val staffBgMod = if (lightStaff) { val staffBgMod = if (lightStaff) {
@@ -310,7 +314,8 @@ fun PracticeScreen(
StaffFullView( StaffFullView(
notes = state.sequence.notes, notes = state.sequence.notes,
currentIndex = state.currentIndex, 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, lightTheme = lightStaff,
beats = state.sequence.beats, beats = state.sequence.beats,
beatType = state.sequence.beatType, beatType = state.sequence.beatType,
@@ -389,12 +394,16 @@ fun PracticeScreen(
} }
} }
if (currentMode == StaffMode.FULL) {
Spacer(modifier = Modifier.height(8.dp))
} else {
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
} }
} }
} }
} }
} }
}
@Composable @Composable
private fun CompletedView( private fun CompletedView(
@@ -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,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 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) {
val file = chooser.selectedFile
// Lettura e unzip fuori dall'EDT
val content = withContext(Dispatchers.Default) {
try { try {
val bytes = chooser.selectedFile.readBytes() extractMusicXmlFromBytes(file.readBytes())
val content = extractMusicXmlFromBytes(bytes)
if (content != null) {
onFileContent(content)
}
} catch (_: Exception) { } catch (_: Exception) {
// File read failed null
}
}
// 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()
} }