refactor(music): align Add-to-Playlist with bookmark-management UI

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.
This commit is contained in:
Claude
2026-05-27 02:37:52 +00:00
parent 058f42b6e8
commit 2210341291
3 changed files with 295 additions and 137 deletions
@@ -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))
}
},
)
}
@@ -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,
)
}
}
}
}
}
+10 -4
View File
@@ -773,11 +773,17 @@
<string name="music_track_pick_cover">Pick cover image</string>
<string name="add_to_music_playlist">Add to playlist</string>
<string name="add_to_music_playlist_title">Add to playlist</string>
<string name="add_to_music_playlist_empty">You don\'t own any playlists yet. Create one to add this track.</string>
<string name="add_to_music_playlist_new">New playlist</string>
<string name="add_to_music_playlist_done">Done</string>
<string name="add_to_music_playlist_empty">You don\'t own any playlists yet. Tap the button to create one.</string>
<string name="music_playlist_new_dialog_title">New playlist</string>
<string name="music_playlist_new_button">New playlist</string>
<string name="music_playlist_new_title_placeholder">Playlist name</string>
<string name="music_playlist_already_contains">Already in this playlist</string>
<string name="music_playlist_create_action">Create</string>
<string name="music_playlist_icon_label">Playlist</string>
<string name="music_playlist_untitled">(untitled playlist)</string>
<string name="music_playlist_presence_indicator">In this playlist</string>
<string name="music_playlist_absence_indicator">Not in this playlist</string>
<string name="music_playlist_add_action_desc">Add this track to the playlist</string>
<string name="music_playlist_remove_action_desc">Remove this track from the playlist</string>
<plurals name="music_playlist_track_count_short">
<item quantity="one">%1$d track</item>
<item quantity="other">%1$d tracks</item>