docs: README con screenshot e badge; rifiniture pentagramma

- README riscritto: badge CI/release, screenshot reali (emulatore),
  funzionalità, architettura, istruzioni di build
- StaffLayout.lineBreaks: capienza riga in peso (√durata) con chiusura
  preferita alla stanghetta (5 test nuovi); usato da StaffFullView
- StaffFullView: travature globali clippate per riga (origine di beat
  corretta anche su righe spezzate a metà battuta)
- Cifre del tempo centrate su asse comune (metri tipo 12/8)
- PracticeSession persiste beats/beatType: il replay dalla cronologia
  conserva il metro (2 test, retrocompatibile con le sessioni salvate)
- fix: escaping Android-style (\' e \") reso letteralmente da compose
  resources in tutte le stringhe localizzate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-07-22 11:38:02 +02:00
1 parent cf795e33d8
commit c00c27e606
15 files changed
+184 -114

No files matched your search

@@ -21,6 +21,8 @@ data class PracticeSession(
val noteResults: List<NoteResult>,
val noteMidiNumbers: List<Int>,
val completed: Boolean = true,
val beats: Int = 4,
val beatType: Int = 4,
) {
val averageCents: Float
get() = if (noteResults.isEmpty()) 0f
@@ -235,6 +235,8 @@ fun AppNavigation() {
currentSequence = NoteSequence(
name = session.sequenceName,
notes = notes,
beats = session.beats,
beatType = session.beatType,
)
currentInstrument = InstrumentPreset.entries.firstOrNull {
it.displayName == session.instrumentName
@@ -44,24 +44,12 @@ fun StaffFullView(
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val availableWidthDp = maxWidth
val noteAreaWidthDp = availableWidthDp - clefWidthDp - 8.dp
val notesPerLine = (noteAreaWidthDp / noteSpacingDp).toInt().coerceAtLeast(4)
// Capienza in peso: una unità di peso (semiminima) occupa ~noteSpacingDp
val capacityWeight = (noteAreaWidthDp / noteSpacingDp).coerceAtLeast(4f)
val bars = StaffLayout.measurePositions(notes, beats, beatType)
// Righe: tetto notesPerLine; si chiude preferibilmente all'ultima stanghetta che ci sta
val lines = mutableListOf<IntRange>()
var lineStart = 0
while (lineStart < notes.size) {
val cap = (lineStart + notesPerLine - 1).coerceAtMost(notes.size - 1)
val lastBarInLine = bars.lastOrNull { it in lineStart..cap }
val lineEnd = when {
cap == notes.size - 1 -> cap
lastBarInLine != null && lastBarInLine > lineStart -> lastBarInLine
else -> cap
}
lines.add(lineStart..lineEnd)
lineStart = lineEnd + 1
}
val allGroups = StaffLayout.beamGroups(notes, beats, beatType)
val lines = StaffLayout.lineBreaks(notes, beats, beatType, capacityWeight)
val lineCount = lines.size
val totalHeightDp = systemHeightDp * lineCount + systemGapDp * (lineCount - 1).coerceAtLeast(0)
@@ -121,7 +109,12 @@ fun StaffFullView(
val lineNotes = notes.subList(r.first, r.last + 1)
val xs = StaffLayout.xPositions(lineNotes, cursor + 10f, size.width - cursor - 18f)
val groups = StaffLayout.beamGroups(lineNotes, beats, beatType)
// Travature globali (origine di beat corretta) clippate alla riga
val groups = allGroups.mapNotNull { g ->
val from = maxOf(g.first, r.first)
val to = minOf(g.last, r.last)
if (to - from >= 1) (from - r.first)..(to - r.first) else null
}
val grouped = groups.flatMap { it }.toSet()
val currentInLine = currentIndex - r.first
@@ -120,6 +120,36 @@ object StaffLayout {
return positions
}
/**
* Spezza la sequenza in righe con capienza espressa in peso (sqrt della durata),
* chiudendo preferibilmente all'ultima stanghetta che entra nella riga.
*/
fun lineBreaks(notes: List<MusicalNote>, beats: Int, beatType: Int, capacityWeight: Float): List<IntRange> {
if (notes.isEmpty()) return emptyList()
val weights = notes.map { kotlin.math.sqrt(quarterUnits(it.duration)).toFloat() }
val bars = measurePositions(notes, beats, beatType).toSet()
val lines = mutableListOf<IntRange>()
var start = 0
while (start < notes.size) {
var acc = 0f
var cap = start
while (cap < notes.size && (cap == start || acc + weights[cap] <= capacityWeight)) {
acc += weights[cap]
cap++
}
cap--
val lastBar = ((start + 1)..cap).lastOrNull { it in bars }
val end = when {
cap >= notes.size - 1 -> notes.size - 1
lastBar != null -> lastBar
else -> cap
}
lines.add(start..end)
start = end + 1
}
return lines
}
/** Centri orizzontali proporzionali a sqrt(durata): la nota lunga respira. */
fun xPositions(notes: List<MusicalNote>, areaStart: Float, areaWidth: Float): List<Float> {
if (notes.isEmpty()) return emptyList()
@@ -175,9 +175,11 @@ internal fun DrawScope.drawTimeSignature(
)
val top = textMeasurer.measure(beats.toString(), style)
val bottom = textMeasurer.measure(beatType.toString(), style)
drawText(top, topLeft = Offset(x, staffBottom - 4f * lineSpacing + (2f * lineSpacing - top.size.height) / 2f))
drawText(bottom, topLeft = Offset(x, staffBottom - 2f * lineSpacing + (2f * lineSpacing - bottom.size.height) / 2f))
return maxOf(top.size.width, bottom.size.width).toFloat()
// Cifre centrate su un asse comune (conta con metri a larghezza mista, es. 12/8)
val width = maxOf(top.size.width, bottom.size.width).toFloat()
drawText(top, topLeft = Offset(x + (width - top.size.width) / 2f, staffBottom - 4f * lineSpacing + (2f * lineSpacing - top.size.height) / 2f))
drawText(bottom, topLeft = Offset(x + (width - bottom.size.width) / 2f, staffBottom - 2f * lineSpacing + (2f * lineSpacing - bottom.size.height) / 2f))
return width
}
/** Chiave a piena opacità. Ritorna la larghezza occupata. */
@@ -183,6 +183,8 @@ class PracticeViewModel : ViewModel() {
noteResults = collectedResults.toList(),
noteMidiNumbers = state.sequence.notes.map { it.midiNumber },
completed = completed,
beats = state.sequence.beats,
beatType = state.sequence.beatType,
)
}