Add store favorites songs; swipe to favorite & load on search result sorted

This commit is contained in:
Hasinjato 2026-08-18 16:15:52 +03:00
parent f85dfd9e9d
commit 57cbb3e441
4 changed files with 214 additions and 75 deletions

View file

@ -126,7 +126,7 @@ class AndroidFileRepository(private val context: Context) : FileRepository {
val shortDisplayPath = longPathToShort(displayPath)
val message = if (shortDisplayPath.endsWith(".json", ignoreCase = true)) {
"Playlist mise à jour"
"${shortDisplayPath.substringBeforeLast(".").replaceFirstChar { it.uppercase() }} mis à jour"
} else {
"Sauvegarde réussie dans : $shortDisplayPath"
}

View file

@ -3,6 +3,7 @@ package mg.dot.feufaro.ui
import SharedScreenModel
import androidx.compose.animation.*
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
@ -15,6 +16,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@ -22,6 +24,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
@ -46,6 +49,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mg.dot.feufaro.data.DrawerItem
import mg.dot.feufaro.getPlatform
import mg.dot.feufaro.pdf.PrintSettings
import mg.dot.feufaro.pdf.defaultPrintSettings
@ -283,7 +287,8 @@ fun MainScreenWithDrawer(
if (showAboutDialog) {
AboutDialog(onDismiss = { showAboutDialog = false })
}
val favoritePaths by sharedScreenModel.playlistItems.collectAsState()
val playlistPaths by sharedScreenModel.playlistItems.collectAsState()
val favoritePaths by sharedScreenModel.favItems.collectAsState()
val keyboardController = LocalSoftwareKeyboardController.current
var isNumericKeyboard by remember { mutableStateOf(true) }
@ -1028,9 +1033,34 @@ fun MainScreenWithDrawer(
modifier = Modifier.fillMaxHeight()
) {
val sortedSongs = remember(filteredSongs) {
filteredSongs.sortedBy { item ->
item.path.startsWith("assets://")
}
filteredSongs.sortedWith(
compareBy<DrawerItem> { item ->
!item.path.startsWith("assets://")
}.thenComparing { a, b ->
val regex = Regex("(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)")
val chunksA = a.title.lowercase().split(regex)
val chunksB = b.title.lowercase().split(regex)
var result = 0
val size = minOf(chunksA.size, chunksB.size)
for (i in 0 until size) {
val chunkA = chunksA[i]
val chunkB = chunksB[i]
val numA = chunkA.toLongOrNull()
val numB = chunkB.toLongOrNull()
result = if (numA != null && numB != null) {
numA.compareTo(numB)
} else {
chunkA.compareTo(chunkB)
}
if (result != 0) break
}
if (result == 0) {
chunksA.size.compareTo(chunksB.size)
} else {
result
}
}
)
}
LazyColumn(Modifier.fillMaxSize()) {
if (sortedSongs.isEmpty() && textInput.isNotEmpty()) {
@ -1063,38 +1093,81 @@ fun MainScreenWithDrawer(
}
itemsIndexed(sortedSongs, key = { _, item -> item.path }) { index, item ->
val isFavorite = favoritePaths.contains(item)
ListItem(
leadingContent = {
Icon(
imageVector = if (item.path.startsWith("assets://")) Icons.Default.ArrowDropDownCircle else Icons.Default.Folder,
contentDescription = null,
modifier = Modifier.size(30.dp)
val onPlaylist = playlistPaths.contains(item)
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { dismissValue ->
if (dismissValue == SwipeToDismissBoxValue.StartToEnd || dismissValue == SwipeToDismissBoxValue.EndToStart) {
sharedScreenModel.toFavorite(item.path)
sharedScreenModel.updateSearchTxt("")
sharedScreenModel.reset()
solfaScreenModel.loadFromFile(item.path)
sharedScreenModel.stopMidi()
sharedScreenModel.seekTo(0f)
sharedScreenModel.setTranspositionInterval(0)
false
} else {
false
}
}
)
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
val color = if (isFavorite) Color.Gray else MaterialTheme.colorScheme.tertiary
val scale by animateFloatAsState(
targetValue = if (dismissState.progress > 0.1f) 1.2f else 0.8f,
label = "iconScale"
)
},
headlineContent = { Text(item.title) },
supportingContent = { Text(item.contentTitle, maxLines = 1) },
trailingContent = {
IconButton(
onClick = { sharedScreenModel.toggleFavorite(item.path) },
modifier = Modifier.size(25.dp)
Box(
modifier = Modifier
.fillMaxSize()
.background(color)
.padding(horizontal = 20.dp),
contentAlignment = if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd)
Alignment.CenterStart else Alignment.CenterEnd
) {
Icon(
imageVector = Icons.Default.Star,
imageVector = if (isFavorite) Icons.Default.FavoriteBorder else Icons.Default.Favorite,
contentDescription = null,
tint = if (!isFavorite) Color.LightGray else Color(
0xFFFFD700
)
modifier = Modifier.scale(scale),
tint = MaterialTheme.colorScheme.onSecondary
)
}
},
modifier = Modifier.clickable {
sharedScreenModel.updateSearchTxt("")
sharedScreenModel.reset()
solfaScreenModel.loadFromFile(item.path)
sharedScreenModel.stopMidi()
sharedScreenModel.seekTo(0f)
sharedScreenModel.setTranspositionInterval(0)
})
}
) {
ListItem(
leadingContent = {
Icon(
imageVector = if (item.path.startsWith("assets://")) Icons.Default.ArrowDropDownCircle else Icons.Default.Folder,
contentDescription = null,
modifier = Modifier.size(30.dp)
)
},
headlineContent = { Text(item.title) },
supportingContent = { Text(item.contentTitle, maxLines = 1) },
trailingContent = {
IconButton(
onClick = { sharedScreenModel.toPlaylist(item.path) },
modifier = Modifier.size(35.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Default.PlaylistAdd,
contentDescription = null,
tint = if (!onPlaylist) Color.LightGray else Color(
0xFFFFD700
)
)
}
},
modifier = Modifier.clickable {
sharedScreenModel.updateSearchTxt("")
sharedScreenModel.reset()
solfaScreenModel.loadFromFile(item.path)
sharedScreenModel.stopMidi()
sharedScreenModel.seekTo(0f)
sharedScreenModel.setTranspositionInterval(0)
})
}
HorizontalDivider()
}
}

View file

@ -23,10 +23,8 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import feufaro.composeapp.generated.resources.Res
@ -61,12 +59,13 @@ fun SimpleDrawerContent(
val internalList by sharedScreenModel.internalItems.collectAsState()
val externalList by sharedScreenModel.externalItems.collectAsState()
val playList by sharedScreenModel.playlistItems.collectAsState()
val favoriteLists by sharedScreenModel.playlistItems.collectAsState()
val favoList by sharedScreenModel.favItems.collectAsState()
val listState = rememberLazyListState()
var internalExpanded by remember { mutableStateOf(false) }
var externalExpanded by remember { mutableStateOf(false) }
var playListExpanded by remember { mutableStateOf(false) }
var favoriteExpanded by remember { mutableStateOf(false) }
val editMode by sharedScreenModel.modeEditor.collectAsState()
val chordView by sharedScreenModel.harmonyView.collectAsState()
@ -156,7 +155,8 @@ fun SimpleDrawerContent(
if (internalExpanded) {
items(internalList) { item ->
val isSelected = item.path == activePath
val isFavorite = favoriteLists.contains(item)
val onPlaylist = playList.contains(item)
val isCurrentFavorite = favoList.contains(item)
NavigationDrawerItem(
icon = {
@ -166,14 +166,14 @@ fun SimpleDrawerContent(
badge = {
IconButton(
onClick = {
sharedScreenModel.toggleFavorite(item.path)
sharedScreenModel.toPlaylist(item.path)
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.PlaylistAddCircle,
contentDescription = null,
tint = if (!isFavorite) Color.LightGray else Color(0xFFFFD700),
tint = if (!onPlaylist) Color.LightGray else Color(0xFFFFD700),
modifier = Modifier.size(18.dp)
)
}
@ -208,7 +208,7 @@ fun SimpleDrawerContent(
if (externalExpanded) {
items(externalList) { item ->
val isSelected = item.path == activePath
val isFavorite = favoriteLists.contains(item)
val onPlaylist = playList.contains(item)
if (item.path != "") {
NavigationDrawerItem(
@ -219,14 +219,14 @@ fun SimpleDrawerContent(
badge = {
IconButton(
onClick = {
sharedScreenModel.toggleFavorite(item.path)
sharedScreenModel.toPlaylist(item.path)
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.PlaylistAddCircle,
contentDescription = null,
tint = if (!isFavorite) Color.LightGray else Color(0xFFFFD700),
tint = if (!onPlaylist) Color.LightGray else Color(0xFFFFD700),
modifier = Modifier.size(18.dp)
)
}
@ -286,11 +286,63 @@ fun SimpleDrawerContent(
}
}
IconButton(
onClick = { sharedScreenModel.toggleFavorite(item.path) },
onClick = { sharedScreenModel.toPlaylist(item.path) },
modifier = Modifier.size(30.dp)
) {
Icon(
imageVector = Icons.Default.Star,
imageVector = Icons.Default.PlaylistRemove,
contentDescription = null,
tint = if (isSelected) Color.LightGray else Color(0xFFFFD700)
)
}
},
selected = isSelected,
onClick = {
scope.launch { drawerState.close() }
sharedScreenModel.reset()
solfaScreenModel.loadFromFile(item.path)
onSongSelected(midi)
},
shape = RoundedCornerShape(5.dp),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
colors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f),
selectedTextColor = MaterialTheme.colorScheme.onSecondary,
)
)
}
}
}
stickyHeader {
DrawerHeaderSticky(
title = "Favoris",
icon = Icons.Default.FavoriteBorder,
isExpanded = favoriteExpanded,
onToggle = { favoriteExpanded = !favoriteExpanded },
count = favoList.size
)
}
if(favoriteExpanded) {
itemsIndexed(favoList) { index, item ->
if (item.path.isNotBlank()) {
val isSelected = item.path == activePath
NavigationDrawerItem(
icon = {
Text("\uD83D\uDDA4", fontSize = 22.sp)
},
label = {
DrawerItemLabel(item)
},
badge = {
IconButton(
onClick = { sharedScreenModel.toFavorite(item.path) },
modifier = Modifier.size(30.dp)
) {
Icon(
imageVector = Icons.Default.StarRate,
contentDescription = null,
tint = if (isSelected) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.tertiary
)
@ -306,8 +358,8 @@ fun SimpleDrawerContent(
shape = RoundedCornerShape(5.dp),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
colors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.5f),
selectedTextColor = MaterialTheme.colorScheme.onTertiary,
selectedContainerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f),
selectedTextColor = MaterialTheme.colorScheme.onSecondary,
)
)
}

View file

@ -1,7 +1,6 @@
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
@ -9,23 +8,16 @@ import com.russhwolf.settings.Settings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import mg.dot.feufaro.FileRepository
import mg.dot.feufaro.data.DrawerItem
import mg.dot.feufaro.data.GridTUOData
import mg.dot.feufaro.data.getCombinedList
import mg.dot.feufaro.solfa.TimeUnitObject
import mg.dot.feufaro.midi.FMediaPlayer
import mg.dot.feufaro.provideSettings
import mg.dot.feufaro.solfa.TUOEditState
import mg.dot.feufaro.solfa.TimeUnitObject
import mg.dot.feufaro.solfa.getAllMarker
import mg.dot.feufaro.viewmodel.MidiMarkers
import java.io.File
@ -122,28 +114,33 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
paths.mapNotNull { path -> allItems.find { it.path == path } }
}.stateIn(screenModelScope, SharingStarted.Lazily, emptyList())
fun savePlaylistToDisk(playlist: List<String>) {
private val favoriteFilename = "favoris.json"
private val _favoritePaths = MutableStateFlow<List<String>>(emptyList())
val favItems: StateFlow<List<DrawerItem>> = combine(_favoritePaths, _drawerItems) { paths, allItems ->
paths.mapNotNull { path -> allItems.find { it.path == path } }
}.stateIn(screenModelScope, SharingStarted.Lazily, emptyList())
fun saveItToDisk(list: List<String>, name: String) {
screenModelScope.launch {
try {
val itemsJson = playlist.joinToString(
val itemsJson = list.joinToString(
separator = "\",\"",
prefix = "[\"",
postfix = "\"]"
).replace("[\"\"]", "[]")
val finalJson = "{\"playlist\": $itemsJson}"
val finalJson = "{\"$name\": $itemsJson}"
val data = finalJson.encodeToByteArray()
fileRepository.saveLocalFile(playlistFilename, data)
fileRepository.saveLocalFile("${name}.json", data)
println("Playlist sauvegardée avec succès !")
println("${name.replaceFirstChar { it.uppercaseChar() }} sauvegardée avec succès !")
} catch (e: Exception) {
println("Erreur lors de la sauvegarde : ${e.message}")
}
}
}
fun toggleFavorite(path: String) {
fun toPlaylist(path: String) {
val current = _playlistPaths.value.toMutableList()
if (current.contains(path)) {
current.remove(path)
@ -151,7 +148,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
current.add(path)
}
_playlistPaths.value = current
savePlaylistToDisk(current)
saveItToDisk(current, "playlist")
}
fun moveUp(index: Int) {
@ -160,7 +157,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
val item = current.removeAt(index)
current.add(index - 1, item)
_playlistPaths.value = current
savePlaylistToDisk(current)
saveItToDisk(current, "playlist")
}
}
fun moveToTop(index: Int) {
@ -168,7 +165,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
val item = current.removeAt(index)
current.add(0, item)
_playlistPaths.value = current
savePlaylistToDisk(current)
saveItToDisk(current, "playlist")
}
fun moveDown(index: Int) {
@ -177,13 +174,25 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
val item = current.removeAt(index)
current.add(index + 1, item)
_playlistPaths.value = current
savePlaylistToDisk(current)
saveItToDisk(current, "playlist")
}
}
fun isInPlaylist(path: String): Boolean {
return _playlistPaths.value.contains(path)
}
fun toFavorite(path: String) {
val current = _favoritePaths.value.toMutableList()
if (current.contains(path)) {
current.remove(path)
} else {
current.add(path)
}
_favoritePaths.value = current
saveItToDisk(current, "favoris")
}
init {
loadItems()
}
@ -192,20 +201,20 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
screenModelScope.launch {
_drawerItems.value = getCombinedList(fileRepository)
}
loadPlaylistFromDisk()
loadPlaylistFromDisk("favoris")
loadPlaylistFromDisk("playlist")
}
private fun loadPlaylistFromDisk() {
private fun loadPlaylistFromDisk(name: String) {
screenModelScope.launch {
try {
val publicFolder = fileRepository.getAppPublicFolder()
val playlistFilePath = "$publicFolder/$playlistFilename"
val jsonString = fileRepository.readFileContent(playlistFilePath)
val tFilePath = "$publicFolder/${name}.json"
val jsonString = fileRepository.readFileContent(tFilePath)
if (jsonString.isNotBlank()) {
val paths = jsonString
.replace("{\"playlist\":", "")
.replace("{\"$name\":", "")
.replace("}", "")
.replace("[", "")
.replace("]", "")
@ -214,11 +223,16 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
.map { it.trim() }
.filter { it.isNotBlank() }
_playlistPaths.value = paths
println("Playlist chargée : ${paths.size} chants")
when(name) {
"playlist" -> { _playlistPaths.value = paths }
"favoris" -> { _favoritePaths.value = paths }
else -> { }
}
println("${name.replaceFirstChar { it.uppercase() }} chargée : ${paths.size} chants")
}
} catch (e: Exception) {
println("Erreur lors du chargement de la playlist : ${e.message}")
println("Erreur lors du chargement de la $name : ${e.message}")
}
}
}