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