From 57cbb3e4417b77c77ef2e8fdf7768d1b69db3d7e Mon Sep 17 00:00:00 2001 From: Hasinjato Date: Tue, 18 Aug 2026 16:15:52 +0300 Subject: [PATCH] Add store favorites songs; swipe to favorite & load on search result sorted --- .../mg/dot/feufaro/AndroidFileRepository.kt | 2 +- .../kotlin/mg/dot/feufaro/ui/DrawerUI.kt | 133 ++++++++++++++---- .../mg/dot/feufaro/ui/SimpleDrawerContent.kt | 78 ++++++++-- .../feufaro/viewmodel/SharedScreenModel.kt | 76 ++++++---- 4 files changed, 214 insertions(+), 75 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidFileRepository.kt b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidFileRepository.kt index 0efd794..60e2cac 100644 --- a/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidFileRepository.kt +++ b/composeApp/src/androidMain/kotlin/mg/dot/feufaro/AndroidFileRepository.kt @@ -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" } diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt index 34019e1..f3d1590 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt @@ -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 { 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() } } diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/SimpleDrawerContent.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/SimpleDrawerContent.kt index d4fdbdf..84caca4 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/SimpleDrawerContent.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/SimpleDrawerContent.kt @@ -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, ) ) } diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt index 8c8dc73..da056de 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt @@ -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) { + private val favoriteFilename = "favoris.json" + private val _favoritePaths = MutableStateFlow>(emptyList()) + val favItems: StateFlow> = combine(_favoritePaths, _drawerItems) { paths, allItems -> + paths.mapNotNull { path -> allItems.find { it.path == path } } + }.stateIn(screenModelScope, SharingStarted.Lazily, emptyList()) + + fun saveItToDisk(list: List, 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}") } } }