From 22103412913f083f87b2e730a04ec67b9912b60a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 02:37:52 +0000 Subject: [PATCH] refactor(music): align Add-to-Playlist with bookmark-management UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compared the music-playlist-management sheet against the existing PostBookmarkListManagementScreen and noticed the playlist sheet was the odd one out — custom TopAppBar with a Done action, inline TextField + Button row for creating new lists, custom Row + Checkbox per playlist, no way to tap into a playlist to view it. The bookmark screen uses a richer Material3 pattern that's already familiar to users. Aligning the music sheet to it: - TopBarWithBackButton (back arrow + title) replaces the bespoke top bar. - Scaffold FAB → NewListButton opens a small AlertDialog with a name field (lighter than the bookmark route to a full edit screen, but consistent in affordance). The previous inline create-row is gone. - Each row is now a MusicPlaylistManagementItem mirroring BookmarkGroupManagementItem: leading icon + total-track-count chip, headline title, supporting "In this playlist" / "Not in this playlist" status text, trailing round IconButton (red Remove / blue Add). - Tapping the row navigates into the playlist's note view (Route.Note with the addressable's tag); tapping the trailing button toggles membership. Previously these were collapsed into a single whole-row tap that toggled but never let the user actually see the playlist. ViewModel unchanged — the same toggle()/createWithTrack() operations now just feed a more conventional UI. --- .../loggedIn/music/AddToMusicPlaylistSheet.kt | 240 ++++++++---------- .../music/MusicPlaylistManagementItem.kt | 178 +++++++++++++ amethyst/src/main/res/values/strings.xml | 14 +- 3 files changed, 295 insertions(+), 137 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt index 89e34e32f6..7b916d4b45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/AddToMusicPlaylistSheet.kt @@ -20,49 +20,41 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.music -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.recalculateWindowInsets import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton import com.vitorpamplona.amethyst.ui.stringRes -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddToMusicPlaylistSheet( trackAddress: String, @@ -72,141 +64,123 @@ fun AddToMusicPlaylistSheet( val vm: AddToMusicPlaylistViewModel = viewModel() vm.init(accountViewModel, trackAddress) + var creating by rememberSaveable { mutableStateOf(false) } + Scaffold( + modifier = Modifier.fillMaxSize().recalculateWindowInsets(), topBar = { - TopAppBar( - title = { - Text( - text = stringRes(R.string.add_to_music_playlist_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - }, - actions = { - Button( - onClick = { nav.popBack() }, - colors = ButtonDefaults.textButtonColors(), - ) { - Text(stringRes(R.string.add_to_music_playlist_done)) - } - }, - colors = TopAppBarDefaults.topAppBarColors(), + TopBarWithBackButton( + caption = stringRes(R.string.add_to_music_playlist_title), + nav = nav, ) }, - ) { pad -> + floatingActionButton = { + FabBottomBarPadded(nav) { + NewListButton(onClick = { creating = true }) + } + }, + ) { contentPadding -> Column( modifier = Modifier - .padding(pad) - .consumeWindowInsets(pad) - .imePadding() - .fillMaxWidth(), + .padding( + top = contentPadding.calculateTopPadding(), + bottom = contentPadding.calculateBottomPadding(), + ).consumeWindowInsets(contentPadding) + .imePadding(), ) { - NewPlaylistRow(vm = vm, accountViewModel = accountViewModel) - - HorizontalDivider() - - val playlists by vm.ownedPlaylists - if (playlists.isEmpty()) { - Text( - text = stringRes(R.string.add_to_music_playlist_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) - } else { - LazyColumn(modifier = Modifier.fillMaxWidth()) { - items(playlists, key = { it.address.toValue() }) { summary -> - PlaylistPickerRow( - summary = summary, - onToggle = { - accountViewModel.launchSigner { - vm.toggle(summary.address) - } - }, - ) - HorizontalDivider() - } - } - } + AddToMusicPlaylistBody(vm = vm, accountViewModel = accountViewModel, nav = nav) } } -} -@Composable -private fun NewPlaylistRow( - vm: AddToMusicPlaylistViewModel, - accountViewModel: AccountViewModel, -) { - var newName by rememberSaveable { mutableStateOf("") } - - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = newName, - onValueChange = { newName = it }, - placeholder = { Text(stringRes(R.string.music_playlist_new_title_placeholder)) }, - modifier = Modifier.weight(1f), - singleLine = true, - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), - ) - - Spacer(Modifier.width(12.dp)) - - Button( - onClick = { - val name = newName.trim() - if (name.isNotBlank()) { - accountViewModel.launchSigner { - if (vm.createWithTrack(name) != null) { - newName = "" - } + if (creating) { + NewMusicPlaylistDialog( + onDismiss = { creating = false }, + onCreate = { name -> + accountViewModel.launchSigner { + if (vm.createWithTrack(name) != null) { + creating = false } } }, - enabled = newName.trim().isNotBlank() && !vm.isWorking.value, - ) { - Text(stringRes(R.string.add_to_music_playlist_new)) + ) + } +} + +@Composable +private fun AddToMusicPlaylistBody( + vm: AddToMusicPlaylistViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val playlists by vm.ownedPlaylists + + if (playlists.isEmpty()) { + Text( + text = stringRes(R.string.add_to_music_playlist_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + return + } + + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier.fillMaxWidth(), + ) { + itemsIndexed( + items = playlists, + key = { _, item -> item.address.toValue() }, + ) { _, summary -> + MusicPlaylistManagementItem( + modifier = Modifier.fillMaxWidth().animateItem(), + playlistTitle = summary.title, + isTrackInPlaylist = summary.containsTrack, + totalTracks = summary.trackCount, + onClick = { nav.nav(Route.Note(summary.address.toValue())) }, + onToggle = { + accountViewModel.launchSigner { + vm.toggle(summary.address) + } + }, + ) } } } @Composable -private fun PlaylistPickerRow( - summary: OwnedPlaylistSummary, - onToggle: () -> Unit, +private fun NewMusicPlaylistDialog( + onDismiss: () -> Unit, + onCreate: (String) -> Unit, ) { - val trackCount = summary.trackCount + var name by rememberSaveable { mutableStateOf("") } - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onToggle) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox(checked = summary.containsTrack, onCheckedChange = { onToggle() }) - Column( - modifier = Modifier.padding(start = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = summary.title.ifBlank { stringRes(R.string.add_to_music_playlist_new) }, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.music_playlist_new_dialog_title)) }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + placeholder = { Text(stringRes(R.string.music_playlist_new_title_placeholder)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), + modifier = Modifier.fillMaxWidth(), ) - Text( - text = pluralStringResource(R.plurals.music_playlist_track_count_short, trackCount, trackCount), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + }, + confirmButton = { + TextButton( + onClick = { onCreate(name.trim()) }, + enabled = name.trim().isNotBlank(), + ) { + Text(stringRes(R.string.music_playlist_create_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + }, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt new file mode 100644 index 0000000000..b2e956c0e2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicPlaylistManagementItem.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.music + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.Size50Modifier +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +/** + * One row in the Add-to-Playlist sheet. Mirrors `BookmarkGroupManagementItem` so the visual + * vocabulary matches the bookmark UI: leading icon + total count, headline = playlist title, + * supporting = membership text, trailing = round add/remove action button. Row click navigates + * to the playlist itself; the trailing button is the toggle. + */ +@Composable +fun MusicPlaylistManagementItem( + modifier: Modifier = Modifier, + playlistTitle: String, + isTrackInPlaylist: Boolean, + totalTracks: Int, + onClick: () -> Unit, + onToggle: () -> Unit, +) { + ListItem( + modifier = modifier.clickable(onClick = onClick), + headlineContent = { + Text( + text = playlistTitle.ifBlank { stringRes(R.string.music_playlist_untitled) }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + PlaylistMembershipStatus(isTrackInPlaylist = isTrackInPlaylist) + }, + leadingContent = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = stringRes(R.string.music_playlist_icon_label), + modifier = Size50Modifier, + ) + Spacer(StdVertSpacer) + Text( + text = pluralStringResource(R.plurals.music_playlist_track_count_short, totalTracks, totalTracks), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + trailingContent = { + PlaylistToggleButton( + isTrackInPlaylist = isTrackInPlaylist, + onToggle = onToggle, + ) + }, + ) +} + +@Composable +private fun PlaylistMembershipStatus(isTrackInPlaylist: Boolean) { + Row( + modifier = HalfHalfVertPadding, + horizontalArrangement = SpacedBy5dp, + verticalAlignment = Alignment.CenterVertically, + ) { + val text = + if (isTrackInPlaylist) { + stringRes(R.string.music_playlist_presence_indicator) + } else { + stringRes(R.string.music_playlist_absence_indicator) + } + + val icon = + if (isTrackInPlaylist) { + MaterialSymbols.PlayCircle + } else { + MaterialSymbols.RemoveCircleOutline + } + + Icon( + symbol = icon, + contentDescription = text, + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = text, + overflow = TextOverflow.MiddleEllipsis, + maxLines = 1, + ) + } +} + +@Composable +private fun PlaylistToggleButton( + isTrackInPlaylist: Boolean, + onToggle: () -> Unit, +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + IconButton( + onClick = onToggle, + modifier = + Modifier + .background( + color = + if (isTrackInPlaylist) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primary + }, + shape = RoundedCornerShape(percent = 80), + ), + ) { + if (isTrackInPlaylist) { + Icon( + symbol = MaterialSymbols.RemoveCircleOutline, + contentDescription = stringRes(R.string.music_playlist_remove_action_desc), + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + } else { + MaterialSymbols.AutoMirrored.PlaylistAdd.let { addSymbol -> + Icon( + symbol = addSymbol, + contentDescription = stringRes(R.string.music_playlist_add_action_desc), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 462b31df3b..02e7d5b1cb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -773,11 +773,17 @@ Pick cover image Add to playlist Add to playlist - You don\'t own any playlists yet. Create one to add this track. - New playlist - Done + You don\'t own any playlists yet. Tap the button to create one. + New playlist + New playlist Playlist name - Already in this playlist + Create + Playlist + (untitled playlist) + In this playlist + Not in this playlist + Add this track to the playlist + Remove this track from the playlist %1$d track %1$d tracks