fix(ci): il pattern gitignore 'scores/' escludeva anche i sorgenti

'scores/' senza slash iniziale ignorava ogni directory 'scores' a
qualsiasi livello: mancavano dal repo il package com.notemusicali.scores,
ScoresScreen e i 12 spartiti bundled. Ancorato a '/scores/' (solo la
libreria alla radice) e aggiunti i file mancanti.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-07-22 10:38:51 +02:00
1 parent 9387b50b67
commit 0add6de716
27 files changed
+890 -3

No files matched your search

@@ -0,0 +1,47 @@
package com.notemusicali.scores
import notemusicali.shared.generated.resources.Res
import org.jetbrains.compose.resources.ExperimentalResourceApi
/**
* Curated set of scores shipped inside the app as compose resources, so every
* platform has content available even before the user picks a folder.
*/
object BundledScores {
private const val PREFIX = "bundled:"
// Resource file name (under files/scores/) to display name
private val manifest = listOf(
"happy_birthday.mxl" to "Happy Birthday",
"ode_to_joy.mxl" to "Inno alla Gioia — Beethoven",
"fur_elise.mxl" to "Per Elisa — Beethoven",
"minuetto_bach.mxl" to "Minuetto in Sol — Bach",
"preludio_bach.mxl" to "Preludio in Do — Bach",
"canone_pachelbel.mxl" to "Canone in Re — Pachelbel",
"gymnopedie_satie.mxl" to "Gymnopédie n.1 — Satie",
"notturno_chopin.mxl" to "Notturno op.9 n.2 — Chopin",
"lago_dei_cigni.mxl" to "Il Lago dei Cigni — Čajkovskij",
"greensleeves.mxl" to "Greensleeves",
"carol_of_the_bells.mxl" to "Carol of the Bells",
"bella_ciao.mxl" to "Bella Ciao",
)
val entries: List<ScoreEntry> = manifest.map { (file, displayName) ->
ScoreEntry(name = displayName, path = PREFIX + file, isDirectory = false)
}
fun isBundled(path: String): Boolean = path.startsWith(PREFIX)
@OptIn(ExperimentalResourceApi::class)
suspend fun read(path: String): ByteArray? {
if (!isBundled(path)) return null
val file = path.removePrefix(PREFIX)
if (manifest.none { it.first == file }) return null
return try {
Res.readBytes("files/scores/$file")
} catch (_: Throwable) {
null
}
}
}
@@ -0,0 +1,3 @@
package com.notemusicali.scores
expect fun extractMusicXmlFromBytes(bytes: ByteArray): String?
@@ -0,0 +1,7 @@
package com.notemusicali.scores
data class ScoreEntry(
val name: String,
val path: String,
val isDirectory: Boolean,
)
@@ -0,0 +1,8 @@
package com.notemusicali.scores
expect object ScoreProvider {
fun listScores(relativePath: String = ""): List<ScoreEntry>
fun readScoreBytes(path: String): ByteArray?
fun listFromUri(treeUri: String, documentId: String = ""): List<ScoreEntry>
fun readFromUri(treeUri: String, documentId: String): ByteArray?
}
@@ -0,0 +1,249 @@
package com.notemusicali.ui.scores
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.MusicNote
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.music.MusicXmlParser
import com.notemusicali.music.NoteSequence
import com.notemusicali.scan.AppSettings
import com.notemusicali.scores.BundledScores
import com.notemusicali.scores.ScoreEntry
import com.notemusicali.scores.ScoreProvider
import com.notemusicali.scores.extractMusicXmlFromBytes
import com.notemusicali.ui.components.BackTopBar
import notemusicali.shared.generated.resources.Res
import notemusicali.shared.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import com.notemusicali.ui.components.GradientCard
import com.notemusicali.ui.components.rememberFolderPickerLauncher
import com.notemusicali.ui.theme.CardGradients
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun ScoresScreen(
onBack: () -> Unit,
onScoreSelected: (NoteSequence) -> Unit,
) {
var externalUri by remember {
mutableStateOf(AppSettings.getString("scores_folder_uri")?.ifEmpty { null })
}
var externalName by remember {
mutableStateOf(AppSettings.getString("scores_folder_name") ?: "")
}
var pathStack by remember { mutableStateOf(listOf<String>()) }
val currentPath = pathStack.lastOrNull() ?: ""
val entries = remember(externalUri, currentPath) {
if (externalUri != null) {
ScoreProvider.listFromUri(externalUri!!, currentPath)
} else {
// App source: curated bundled scores first, then any file the user
// dropped in the app's scores directory
val bundled = if (currentPath.isEmpty()) BundledScores.entries else emptyList()
bundled + ScoreProvider.listScores(currentPath)
}
}
var loadingPath by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val folderPicker = rememberFolderPickerLauncher { uri, name ->
externalUri = uri
externalName = name
pathStack = emptyList()
AppSettings.putString("scores_folder_uri", uri)
AppSettings.putString("scores_folder_name", name)
}
val scoresTitle = stringResource(Res.string.scores_title)
val title = if (pathStack.isEmpty()) {
if (externalUri != null) externalName.ifEmpty { scoresTitle } else scoresTitle
} else {
val last = pathStack.last()
last.substringAfterLast("/").substringAfterLast(":").ifEmpty { last }
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Column(modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight()) {
BackTopBar(
title = title,
onBack = {
if (pathStack.isNotEmpty()) {
pathStack = pathStack.dropLast(1)
} else {
onBack()
}
},
contentColor = MaterialTheme.colorScheme.primary,
)
// Source selector
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (externalUri != null) stringResource(Res.string.folder_named, externalName) else stringResource(Res.string.folder_app),
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.4f),
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
TextButton(onClick = { folderPicker() }) {
Icon(
Icons.Outlined.Folder,
contentDescription = stringResource(Res.string.change_folder),
modifier = Modifier.size(16.dp),
tint = Color.White.copy(alpha = 0.6f),
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(Res.string.change), fontSize = 12.sp, color = Color.White.copy(alpha = 0.6f))
}
if (externalUri != null) {
TextButton(onClick = {
externalUri = null
externalName = ""
pathStack = emptyList()
AppSettings.putString("scores_folder_uri", "")
AppSettings.putString("scores_folder_name", "")
}) {
Text("Reset", fontSize = 12.sp, color = Color.White.copy(alpha = 0.6f))
}
}
}
if (entries.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.no_scores_found),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 16.dp),
) {
items(entries) { entry ->
ScoreEntryCard(
entry = entry,
isLoading = loadingPath == entry.path,
onClick = {
if (entry.isDirectory) {
pathStack = pathStack + entry.path
} else if (loadingPath == null) {
loadingPath = entry.path
scope.launch {
val sequence = withContext(Dispatchers.Default) {
val bytes = when {
BundledScores.isBundled(entry.path) -> BundledScores.read(entry.path)
externalUri != null -> ScoreProvider.readFromUri(externalUri!!, entry.path)
else -> ScoreProvider.readScoreBytes(entry.path)
}
bytes ?: return@withContext null
val xml = extractMusicXmlFromBytes(bytes)
?: return@withContext null
val seq = MusicXmlParser.parse(xml, entry.name)
if (seq.notes.isNotEmpty()) seq else null
}
loadingPath = null
if (sequence != null) {
onScoreSelected(sequence)
}
}
}
},
)
}
}
}
}
}
}
@Composable
private fun ScoreEntryCard(
entry: ScoreEntry,
isLoading: Boolean,
onClick: () -> Unit,
) {
GradientCard(
gradient = if (entry.isDirectory) CardGradients.scores else CardGradients.scoreFile,
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
color = Color.White.copy(alpha = 0.8f),
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = if (entry.isDirectory) Icons.Outlined.Folder else Icons.Outlined.MusicNote,
contentDescription = if (entry.isDirectory) stringResource(Res.string.folder) else stringResource(Res.string.score_file),
modifier = Modifier.size(28.dp),
tint = Color.White.copy(alpha = 0.7f),
)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = entry.name,
fontSize = 16.sp,
fontWeight = if (entry.isDirectory) FontWeight.SemiBold else FontWeight.Normal,
color = Color.White,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}