From d53ac853a5215519102a0d20a6850651941d7952 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 10 Jul 2026 13:06:25 +0600 Subject: [PATCH] refactor: replace button components with themed variants and introduce ListRowTile --- .../spotube/core/ui/base/ListRowTile.kt | 132 ++++++++++++ .../spotube/modules/artist/ArtistScreen.kt | 33 ++- .../shell/player_queue/PlayerQueueContent.kt | 198 ++++++++---------- 3 files changed, 241 insertions(+), 122 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt new file mode 100644 index 00000000..4969bc4b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.ui.base + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.unit.dp + +@Composable +fun ListRowTile( + selected: Boolean = false, + enabled: Boolean = true, + onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, + leading: @Composable (() -> Unit)? = null, + title: @Composable (() -> Unit)? = null, + subtitle: @Composable (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, +) { + val rowTheme = LocalBaseUITheme.current.listRowTile + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val isHovered by interactionSource.collectIsHoveredAsState() + val isFocused by interactionSource.collectIsFocusedAsState() + + val background = when { + isPressed -> rowTheme.background.pressed + isHovered -> rowTheme.background.hovered + isFocused -> rowTheme.background.focused + selected -> rowTheme.background.selected + else -> rowTheme.background.normal + } + val shape = when { + isPressed -> rowTheme.shape.pressed + isHovered -> rowTheme.shape.hovered + isFocused -> rowTheme.shape.focused + selected -> rowTheme.shape.selected + else -> rowTheme.shape.normal + } + val borderDef = when { + isPressed -> rowTheme.border.pressed + isHovered -> rowTheme.border.hovered + isFocused -> rowTheme.border.focused + selected -> rowTheme.border.selected + else -> rowTheme.border.normal + } + val shadowDef = when { + isPressed -> rowTheme.shadow.pressed + isHovered -> rowTheme.shadow.hovered + isFocused -> rowTheme.shadow.focused + selected -> rowTheme.shadow.selected + else -> rowTheme.shadow.normal + } + + val mod = modifier + .fillMaxWidth() + .shadow(shadowDef.elevation, shape, shadowDef.clip, shadowDef.ambientColor, shadowDef.spotColor) + .clip(shape) + .border(borderDef.width, borderDef.color, shape) + .background(background, shape) + .then( + if (onClick != null) { + Modifier.clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + } else Modifier + ) + .padding(rowTheme.padding) + + Row( + modifier = mod, + verticalAlignment = Alignment.CenterVertically, + ) { + if (leading != null) { + leading() + Spacer(Modifier.width(12.dp)) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + if (title != null) { + title() + } + if (subtitle != null) { + Spacer(Modifier.width(2.dp)) + subtitle() + } + } + if (trailing != null) { + Spacer(Modifier.width(12.dp)) + trailing() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index 4f2d5abd..83d7ca59 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -36,12 +36,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -49,7 +45,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -61,10 +56,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage -import compose.icons.FeatherIcons -import compose.icons.feathericons.Play -import compose.icons.feathericons.PlusSquare -import compose.icons.feathericons.User import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist @@ -76,6 +67,10 @@ import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.share.ShareService +import dev.krtirtho.spotube.core.ui.base.PrimaryButton +import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton +import dev.krtirtho.spotube.core.ui.base.SecondaryButton +import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ArtistCard @@ -89,6 +84,10 @@ import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import dev.krtirtho.spotube.resources.iconsax.User import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import org.koin.compose.koinInject @@ -349,7 +348,7 @@ private fun ArtistErrorContent( color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyLarge, ) - Button(onClick = onRetry) { + PrimaryButton(onClick = onRetry) { Text("Retry") } } @@ -452,7 +451,7 @@ private fun ArtistAvatar( ) } else { Icon( - imageVector = FeatherIcons.User, + imageVector = Iconsax.User, contentDescription = artist.name, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(size * 0.4f), @@ -516,11 +515,11 @@ private fun ArtistHeaderActions( verticalAlignment = Alignment.CenterVertically, ) { if (isSaved) { - FilledTonalButton(onClick = onFollowClick) { + SecondaryButton(onClick = onFollowClick) { Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) } } else { - Button(onClick = onFollowClick) { + PrimaryButton(onClick = onFollowClick) { Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) } } @@ -546,15 +545,15 @@ private fun TopTracksHeader( ) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - FilledIconButton(onClick = onPlay) { + PrimaryIconButton(onClick = onPlay) { Icon( - imageVector = FeatherIcons.Play, + imageVector = Iconsax.IconsaxPlay, contentDescription = "Play top tracks", ) } - FilledTonalIconButton(onClick = onAddToQueue) { + SecondaryIconButton(onClick = onAddToQueue) { Icon( - imageVector = FeatherIcons.PlusSquare, + imageVector = Iconsax.IconsaxAddSquare, contentDescription = "Add top tracks to queue", ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 529af12f..9262e8fe 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -20,6 +20,8 @@ package dev.krtirtho.spotube.modules.shell.player_queue import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -60,7 +62,10 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.rememberLogger +import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.GhostIconButton +import dev.krtirtho.spotube.core.ui.base.IconButton +import dev.krtirtho.spotube.core.ui.base.ListRowTile import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton import dev.krtirtho.spotube.core.ui.base.TextField @@ -191,9 +196,9 @@ fun PlayerQueueContent( singleLine = true, modifier = Modifier.weight(1f), ) - SecondaryIconButton( + IconButton( onClick = viewModel::clearQueue, - theme = LocalBaseUITheme.current.iconButtons.secondary.copyShape(MaterialTheme.shapes.small), + theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small), ) { Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") } @@ -206,24 +211,23 @@ fun PlayerQueueContent( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = lazyListState, - contentPadding = PaddingValues(bottom = 8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - items(displayList, key = { item -> item.id }) { item -> - ReorderableItem(reorderableLazyListState, key = item.id) { isDragging -> - val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) - QueueItemRow( - item = item, - isDragging = isDragging, - elevation = elevation, - reorderScope = if (isFiltered) null else this, - onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, - onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, - onDragStopped = ::finalizeReorder, - ) + Card { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + contentPadding = PaddingValues(bottom = 8.dp), + ) { + items(displayList, key = { item -> item.id }) { item -> + ReorderableItem(reorderableLazyListState, key = item.id) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) + QueueItemRow( + item = item, + reorderScope = if (isFiltered) null else this, + onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, + onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, + onDragStopped = ::finalizeReorder, + ) + } } } } @@ -235,8 +239,6 @@ fun PlayerQueueContent( @Composable private fun QueueItemRow( item: QueueItemUi, - isDragging: Boolean, - elevation: androidx.compose.ui.unit.Dp, reorderScope: sh.calvin.reorderable.ReorderableCollectionItemScope?, onPlayClick: () -> Unit, onRemoveClick: () -> Unit, @@ -244,92 +246,78 @@ private fun QueueItemRow( ) { var showMenu by remember { mutableStateOf(false) } - val rowColor = if (item.isCurrent) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerHigh - } - - Surface( - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.medium) - .highlight(LocalBaseUITheme.current.buttons.secondary.colors.normal.highlight) - .clickable(onClick = onPlayClick), - shadowElevation = elevation, - tonalElevation = if (item.isCurrent) 2.dp else 0.dp, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(72.dp) - .background(rowColor) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Iconsax.IconsaxDragHandle, - contentDescription = if (reorderScope != null) "Reorder" else null, + ListRowTile( + onClick = onPlayClick, + selected = item.isCurrent, + modifier = Modifier, + leading = { + Row( modifier = Modifier - .size(24.dp) - .then( - if (reorderScope != null) { - with(reorderScope) { Modifier.draggableHandle(onDragStopped = onDragStopped) } - } else { - Modifier - }, - ), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Box( - modifier = Modifier - .size(48.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, + .height(72.dp), + verticalAlignment = Alignment.CenterVertically, ) { - if (item.imageUrl != null) { - AsyncImage( - model = item.imageUrl, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } else { - Text( - text = "${item.originalIndex + 1}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Icon( + Iconsax.IconsaxDragHandle, + contentDescription = if (reorderScope != null) "Reorder" else null, + modifier = Modifier + .size(24.dp) + .then( + if (reorderScope != null) { + with(reorderScope) { Modifier.draggableHandle(onDragStopped = onDragStopped) } + } else { + Modifier + }, + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Box( + modifier = Modifier + .size(48.dp) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (item.imageUrl != null) { + AsyncImage( + model = item.imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Text( + text = "${item.originalIndex + 1}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } - - Spacer(modifier = Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = item.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (item.isCurrent) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - Text( - text = item.subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - Spacer(modifier = Modifier.width(8.dp)) - + }, + title = { + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (item.isCurrent) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + }, + subtitle = { + Text( + text = item.subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + trailing = { Text( text = item.durationMs.toDurationString(), style = MaterialTheme.typography.bodySmall, @@ -366,7 +354,7 @@ private fun QueueItemRow( } } } - } + ) } private fun QueueEntry.toQueueDisplayData(): Tuple4 {