From 9d79d055ba194c6d48415d0968956f261a1f36d3 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 16 Jul 2026 01:28:48 +0600 Subject: [PATCH] feat: implement settings section cards for updates, desktop, language, caching, and plugins --- .../krtirtho/spotube/core/ui/base/Dialog.kt | 186 +++++++ .../spotube/core/ui/base/DropdownMenu.kt | 172 +++++++ .../krtirtho/spotube/core/ui/base/Radio.kt | 177 +++++++ .../krtirtho/spotube/core/ui/base/Theme.kt | 64 +++ .../component/AdaptiveDropdownBottomSheet.kt | 158 +----- .../spotube/modules/plugin/PluginScreen.kt | 487 +++++++++--------- .../plugin/components/InstallSection.kt | 21 +- .../modules/plugin/components/PluginCard.kt | 34 +- .../components/InteractiveSettingCards.kt | 163 +++--- .../settings/components/SettingsCardItem.kt | 85 +-- .../settings/sections/AppearanceSection.kt | 206 ++++---- .../modules/settings/sections/CacheSection.kt | 155 +++--- .../settings/sections/DesktopSection.kt | 68 +-- .../settings/sections/DownloadsSection.kt | 278 +++++----- .../sections/LanguageRegionSection.kt | 100 ++-- .../settings/sections/PlaybackSection.kt | 213 ++++---- .../settings/sections/PluginsSection.kt | 68 +-- .../sections/SettingsSectionSupport.kt | 30 ++ .../settings/sections/UpdatesSection.kt | 37 +- 19 files changed, 1562 insertions(+), 1140 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt new file mode 100644 index 00000000..67dd0abe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt @@ -0,0 +1,186 @@ +/* + * 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.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +private val DefaultDialogMaxWidth: Dp = 440.dp + +@Composable +fun ThemedDialog( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + title: @Composable (() -> Unit)? = null, + actions: @Composable (() -> Unit)? = null, + theme: BaseUITheme.DialogTheme? = null, + properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false), + content: @Composable () -> Unit, +) { + val dialogTheme = theme ?: LocalBaseUITheme.current.dialog + + Dialog( + onDismissRequest = onDismissRequest, + properties = properties, + ) { + AnimatedVisibility( + visible = true, + enter = scaleIn( + initialScale = 0.92f, + animationSpec = tween(280), + ) + fadeIn(animationSpec = tween(200)), + exit = scaleOut( + targetScale = 0.92f, + animationSpec = tween(180), + ) + fadeOut(animationSpec = tween(150)), + ) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .widthIn(max = DefaultDialogMaxWidth) + .shadow( + elevation = dialogTheme.shadow.elevation, + shape = dialogTheme.shadow.let { if (it.clip) dialogTheme.shape else RoundedCornerShape(0.dp) }, + ambientColor = dialogTheme.shadow.ambientColor, + spotColor = dialogTheme.shadow.spotColor, + ) + .clip(dialogTheme.shape) + .background(dialogTheme.background, dialogTheme.shape) + .border( + BorderStroke(dialogTheme.border.width, dialogTheme.border.color), + dialogTheme.shape + ) + .highlight(Color.White.copy(alpha = 0.08f)), + ) { + Column(modifier = Modifier.widthIn(max = DefaultDialogMaxWidth)) { + if (title != null) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 24.dp), + ) { + title() + } + Spacer(Modifier.height(8.dp)) + } + + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding( + start = 24.dp, + end = 24.dp, + top = if (title == null) 24.dp else 0.dp, + ), + ) { + content() + } + + if (actions != null) { + Spacer(Modifier.height(16.dp)) + Column(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + actions() + } + } + } else { + Spacer(Modifier.height(24.dp)) + } + } + } + } + } + } +} + +@Preview +@Composable +private fun ThemedDialogPreview() { + MaterialTheme { + val theme = rememberBaseUITheme() + CompositionLocalProvider(LocalBaseUITheme provides theme) { + var show by remember { mutableStateOf(true) } + androidx.compose.material3.Surface( + color = MaterialTheme.colorScheme.background, + ) { + PrimaryButton(onClick = { show = true }) { + Text("Open Dialog") + } + if (show) { + ThemedDialog(onDismissRequest = { show = false }) { + Text( + "This is a themed dialog with glass-like gradient, shadow, and scale animation.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt new file mode 100644 index 00000000..3ac8cc56 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt @@ -0,0 +1,172 @@ +/* + * 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.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp + +@Composable +fun DropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + offset: DpOffset = DpOffset(0.dp, 4.dp), + theme: BaseUITheme.DropdownMenuTheme? = null, + content: @Composable ColumnScope.() -> Unit, +) { + val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu + + androidx.compose.material3.DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + offset = offset, + modifier = modifier + .widthIn(min = 180.dp) + .shadow( + elevation = menuTheme.shadow.elevation, + shape = menuTheme.shape, + ambientColor = menuTheme.shadow.ambientColor, + spotColor = menuTheme.shadow.spotColor, + ) + .clip(menuTheme.shape) + .background(menuTheme.background, menuTheme.shape) + .border( + BorderStroke(menuTheme.border.width, menuTheme.border.color), + menuTheme.shape, + ), + ) { + content() + } +} + +@Composable +fun DropdownMenuItem( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + leadingIcon: ImageVector? = null, + selected: Boolean = false, + theme: BaseUITheme.DropdownMenuTheme? = null, +) { + val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + Row( + modifier = modifier + .fillMaxWidth() + .clip(menuTheme.shape) + .hoverable(interactionSource) + .then( + if (isHovered && enabled) { + Modifier.background(menuTheme.itemHoverBackground) + } else { + Modifier + } + ) + .then( + if (isHovered && enabled) { + Modifier.highlight(menuTheme.itemHighlight) + } else { + Modifier + } + ) + .clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .padding(menuTheme.itemPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (enabled) { + menuTheme.itemForeground + } else { + menuTheme.itemForeground.copy(alpha = 0.38f) + }, + ) + } + + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) { + menuTheme.itemForeground + } else { + menuTheme.itemForeground.copy(alpha = 0.38f) + }, + modifier = Modifier.weight(1f), + maxLines = 1, + softWrap = false, + ) + } +} + +@Composable +fun DropdownMenuDivider( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt new file mode 100644 index 00000000..5007db43 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt @@ -0,0 +1,177 @@ +/* + * 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.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.graphics.Brush +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +private val RadioSize = 22.dp +private val RadioDotSize = 10.dp + +private data class ResolvedRadioState( + val colors: BaseUITheme.ButtonColors, + val shape: Shape, + val shadow: BaseUITheme.Shadow, + val border: BaseUITheme.Border, +) + +@Composable +private fun resolveRadioState( + style: BaseUITheme.ButtonStyle, + isPressed: Boolean, + isHovered: Boolean, +): ResolvedRadioState { + return when { + isPressed -> ResolvedRadioState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed) + isHovered -> ResolvedRadioState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered) + else -> ResolvedRadioState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal) + } +} + +@Composable +fun Radio( + selected: Boolean, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + theme: BaseUITheme.CheckBoxTheme? = null, +) { + val baseTheme = LocalBaseUITheme.current.checkBox + val radioTheme = theme ?: baseTheme + val source = remember { MutableInteractionSource() } + val isPressed by source.collectIsPressedAsState() + val isHovered by source.collectIsHoveredAsState() + + val style = if (selected) radioTheme.selected else radioTheme.unselected + val resolved = resolveRadioState(style, isPressed, isHovered) + val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp + + val dotProgress by animateFloatAsState( + targetValue = if (selected) 1f else 0f, + animationSpec = tween(durationMillis = 180), + label = "radioDot", + ) + + Box( + modifier = modifier + .size(RadioSize) + .graphicsLayer { + translationY = lift.toPx() + shape = CircleShape + clip = true + } + .then( + if (resolved.shadow.elevation > 0.dp) { + Modifier.shadow( + elevation = resolved.shadow.elevation, + shape = CircleShape, + ambientColor = resolved.shadow.ambientColor, + spotColor = resolved.shadow.spotColor, + ) + } else Modifier + ) + .clip(CircleShape) + .background(resolved.colors.background, CircleShape) + .border(BorderStroke(0.5.dp, resolved.border.color), CircleShape) + .highlight(resolved.colors.highlight) + .then( + if (onClick != null) { + Modifier + .hoverable(interactionSource = source, enabled = enabled) + .clickable( + interactionSource = source, + indication = ripple(bounded = true, radius = RadioSize / 2), + enabled = enabled, + onClick = onClick, + ) + } else { + Modifier + } + ), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(RadioDotSize * dotProgress) + .clip(CircleShape) + .background(radioTheme.checkmarkColor, CircleShape), + ) + } +} + +@Preview +@Composable +private fun RadioPreview() { + MaterialTheme { + val theme = rememberBaseUITheme() + CompositionLocalProvider(LocalBaseUITheme provides theme) { + androidx.compose.material3.Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.padding(24.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Radio(selected = true, onClick = {}) + Text("Selected", style = MaterialTheme.typography.bodyMedium) + } + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Radio(selected = false, onClick = {}) + Text("Unselected", style = MaterialTheme.typography.bodyMedium) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt index 2652d964..e195ffc2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt @@ -51,6 +51,8 @@ data class BaseUITheme( val toggle: ToggleTheme, val card: CardTheme, val listRowTile: ListRowTheme, + val dialog: DialogTheme, + val dropdownMenu: DropdownMenuTheme, ) { @Stable @@ -159,6 +161,27 @@ data class BaseUITheme( val shadowElevation: Dp, ) + data class DialogTheme( + val background: Brush, + val scrim: Color, + val shape: Shape, + val border: Border, + val shadow: Shadow, + val padding: PaddingValues, + ) + + data class DropdownMenuTheme( + val background: Brush, + val shape: Shape, + val border: Border, + val shadow: Shadow, + val itemHoverBackground: Brush, + val itemForeground: Color, + val itemHighlight: Color, + val padding: PaddingValues, + val itemPadding: PaddingValues, + ) + data class SliderTheme( val trackActiveColor: InteractionState, val trackInactiveColor: InteractionState, @@ -977,6 +1000,47 @@ fun rememberBaseUITheme(): BaseUITheme { disabled = scheme.onSurface.copy(alpha = 0.38f), ), ), + dialog = BaseUITheme.DialogTheme( + background = Brush.verticalGradient( + listOf( + scheme.surface, + scheme.surfaceContainerHigh, + ) + ), + scrim = Color.Black.copy(alpha = 0.42f), + shape = RoundedCornerShape(16.dp), + border = BaseUITheme.Border(border, 0.5.dp), + shadow = BaseUITheme.Shadow( + 24.dp, + true, + shadowColor.copy(alpha = 0.3f), + shadowColor.copy(alpha = 0.4f) + ), + padding = PaddingValues(24.dp), + ), + dropdownMenu = BaseUITheme.DropdownMenuTheme( + background = Brush.verticalGradient( + listOf( + scheme.surfaceContainerHigh, + scheme.surfaceContainer, + ) + ), + shape = RoundedCornerShape(12.dp), + border = BaseUITheme.Border(border, 0.5.dp), + shadow = BaseUITheme.Shadow( + 12.dp, + true, + shadowColor.copy(alpha = 0.2f), + shadowColor.copy(alpha = 0.25f) + ), + itemHoverBackground = Brush.verticalGradient( + listOf(containerLighter, containerDarker) + ), + itemForeground = scheme.onSurface, + itemHighlight = highlight, + padding = PaddingValues(4.dp), + itemPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), + ), ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt index 49a448f4..b87433f7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt @@ -17,6 +17,7 @@ package dev.krtirtho.spotube.core.ui.component +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -33,10 +34,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -64,6 +65,9 @@ import androidx.compose.ui.unit.dp import compose.icons.FeatherIcons import compose.icons.feathericons.Check import dev.krtirtho.spotube.core.ui.base.TextField +import dev.krtirtho.spotube.core.ui.base.DropdownMenu +import dev.krtirtho.spotube.core.ui.base.DropdownMenuItem +import dev.krtirtho.spotube.core.ui.base.DropdownMenuDivider import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch @@ -105,10 +109,9 @@ fun AdaptiveDropdownBottomSheet( trigger { expanded = true } if (isLargeScreen) { - ShadcnDropdownMenu( + DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, - minWidth = menuMinWidth, ) { if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) { header() @@ -137,28 +140,24 @@ fun AdaptiveDropdownBottomSheet( ) } - val hasSelection = items.any { it.selected } - - items - .forEach { item -> - if (query.isNotBlank() && filter != null && !filter(item, query)) { - return@forEach - } - if (item.dividerBefore) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - ShadcnDropdownMenuItem( - item = item, - onClick = { - item.onClick() - expanded = false - }, - hasSelection = hasSelection, - ) + items.forEach { item -> + if (query.isNotBlank() && filter != null && !filter(item, query)) { + return@forEach } + if (item.dividerBefore) { + DropdownMenuDivider() + } + DropdownMenuItem( + text = item.label, + onClick = { + item.onClick() + expanded = false + }, + enabled = item.enabled, + selected = item.selected, + leadingIcon = item.icon, + ) + } } } else { if (expanded) { @@ -177,117 +176,6 @@ fun AdaptiveDropdownBottomSheet( } } -@Composable -private fun ShadcnDropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - minWidth: Dp, - content: @Composable () -> Unit, -) { - val shape = RoundedCornerShape(6.dp) - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - offset = DpOffset(x = 0.dp, y = 4.dp), - modifier = Modifier - .width(minWidth) - .shadow( - elevation = 2.dp, - shape = shape, - ambientColor = Color.Black.copy(alpha = 0.06f), - spotColor = Color.Black.copy(alpha = 0.1f), - ) - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - shape = shape, - ) - .background( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = shape, - ) - .clip(shape), - ) { - Column( - modifier = Modifier.padding(vertical = 4.dp), - ) { - content() - } - } -} - -@Composable -private fun ShadcnDropdownMenuItem( - item: AdaptiveMenuItem, - onClick: () -> Unit, - hasSelection: Boolean, -) { - val interactionSource = remember { MutableInteractionSource() } - val isHovered by interactionSource.collectIsHoveredAsState() - - val hoverColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) - - Row( - modifier = Modifier - .fillMaxWidth() - .hoverable(interactionSource) - .background( - color = if (isHovered && item.enabled) hoverColor else Color.Transparent, - ) - .clickable( - enabled = item.enabled, - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (item.icon != null) { - Icon( - imageVector = item.icon, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = if (item.enabled) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) - } else if (item.selected) { - Icon( - imageVector = FeatherIcons.Check, - contentDescription = "Selected", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else if (hasSelection) { - Spacer(modifier = Modifier.size(16.dp)) - } - - Text( - text = item.label, - style = MaterialTheme.typography.bodyMedium, - color = if (item.enabled) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - modifier = Modifier.weight(1f), - ) - - if (item.selected && item.icon != null) { - Icon( - imageVector = FeatherIcons.Check, - contentDescription = "Selected", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AdaptiveBottomSheetContent( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt index c4b7190c..5ae4a45f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt @@ -17,7 +17,6 @@ package dev.krtirtho.spotube.modules.plugin -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -29,14 +28,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -64,6 +59,8 @@ import compose.icons.feathericons.ExternalLink import compose.icons.feathericons.FileText import compose.icons.feathericons.Music import compose.icons.feathericons.Package +import dev.krtirtho.spotube.core.ui.base.Card +import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem @@ -219,23 +216,41 @@ fun PluginScreen( } // ── Default ability plugin selectors ───────────────── - items(PluginAbility.entries.size) { index -> - val ability = PluginAbility.entries[index] - val selectedPlugin = state.selectedPlugins[ability] - DefaultAbilityPluginSelector( - ability = ability, - selectedPlugin = selectedPlugin, - state = when (ability) { - PluginAbility.METADATA -> pluginManager.metadataPlugins - PluginAbility.AUDIO -> pluginManager.audioPlugins - PluginAbility.LYRICS -> pluginManager.lyricsPlugins - PluginAbility.SCROBBLE -> pluginManager.scrobblePlugins - }, - onSelected = { plugin -> - pluginManager.setSelectedPlugin(ability, plugin) - }, - onManagePlugins = { } - ) + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 4.dp) + ) { + PluginAbility.entries.forEachIndexed { index, ability -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + val selectedPlugin = state.selectedPlugins[ability] + DefaultAbilityPluginSelector( + ability = ability, + selectedPlugin = selectedPlugin, + state = when (ability) { + PluginAbility.METADATA -> pluginManager.metadataPlugins + PluginAbility.AUDIO -> pluginManager.audioPlugins + PluginAbility.LYRICS -> pluginManager.lyricsPlugins + PluginAbility.SCROBBLE -> pluginManager.scrobblePlugins + }, + onSelected = { plugin -> + pluginManager.setSelectedPlugin(ability, plugin) + }, + onManagePlugins = { } + ) + } + } + } } if (state.plugins.isEmpty()) { @@ -294,66 +309,85 @@ fun PluginScreen( modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) ) } - items(state.plugins) { plugin -> - val isSelected = state.selectedPlugins.containsValue(plugin) - val selectedAbility = state.selectedPlugins - .entries - .firstOrNull { (_, selectedPlugin) -> selectedPlugin.id == plugin.id } - ?.key - val selectedService = selectedAbility?.let { ability -> - activeServices?.get(ability) - } + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + state.plugins.forEachIndexed { index, plugin -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + val isSelected = state.selectedPlugins.containsValue(plugin) + val selectedAbility = state.selectedPlugins + .entries + .firstOrNull { (_, selectedPlugin) -> selectedPlugin.id == plugin.id } + ?.key + val selectedService = selectedAbility?.let { ability -> + activeServices?.get(ability) + } - var requiresAuth by remember(plugin.id, selectedService) { - mutableStateOf(false) - } - var isLoggedIn by remember(plugin.id, selectedService) { - mutableStateOf(false) - } + var requiresAuth by remember(plugin.id, selectedService) { + mutableStateOf(false) + } + var isLoggedIn by remember(plugin.id, selectedService) { + mutableStateOf(false) + } - LaunchedEffect(plugin.id, selectedService) { - requiresAuth = false - isLoggedIn = false - val service = selectedService ?: return@LaunchedEffect + LaunchedEffect(plugin.id, selectedService) { + requiresAuth = false + isLoggedIn = false + val service = selectedService ?: return@LaunchedEffect - service.use { - val pluginRequiresAuth = coreAPI.requiresAuthentication - requiresAuth = pluginRequiresAuth - if (!pluginRequiresAuth) return@use + service.use { + val pluginRequiresAuth = coreAPI.requiresAuthentication + requiresAuth = pluginRequiresAuth + if (!pluginRequiresAuth) return@use - coreAPI.loggedInFlow.collect { loggedIn -> - isLoggedIn = loggedIn + coreAPI.loggedInFlow.collect { loggedIn -> + isLoggedIn = loggedIn + } + } + } + + PluginCard( + plugin = plugin, + isSelected = isSelected, + onRemove = { + scope.launch { pluginManager.removePlugin(plugin) } + }, + isLoggedIn = isLoggedIn, + onLogin = if (requiresAuth && selectedService != null) { + { + pluginManager.launchTask { + selectedService.use { coreAPI.login() } + } + } + } else { + null + }, + onLogout = if (requiresAuth && selectedService != null) { + { + pluginManager.launchTask { + selectedService.use { coreAPI.logout() } + } + // should clear webview data after logout + scope.launch { webviewController.clearData() } + } + } else { + null + } + ) } } } - - PluginCard( - plugin = plugin, - isSelected = isSelected, - onRemove = { scope.launch { pluginManager.removePlugin(plugin) } }, - isLoggedIn = isLoggedIn, - onLogin = if (requiresAuth && selectedService != null) { - { - pluginManager.launchTask { - selectedService.use { coreAPI.login() } - } - } - } else { - null - }, - onLogout = if (requiresAuth && selectedService != null) { - { - pluginManager.launchTask { - selectedService.use { coreAPI.logout() } - } - // should clear webview data after logout - scope.launch { webviewController.clearData() } - } - } else { - null - } - ) } } } @@ -419,187 +453,150 @@ fun DefaultAbilityPluginSelector( ) } - Column( + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - border = BorderStroke( - width = 1.dp, - color = if (selectedPlugin != null) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) - } else { - MaterialTheme.colorScheme.outlineVariant - } - ) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) + Surface( + modifier = Modifier.clip(RoundedCornerShape(8.dp)), + color = when (ability) { + PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) + PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) + PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) + PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) + } ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Surface( - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = when (ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) - PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) - PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) - } - ) { - Icon( - imageVector = when (ability) { - PluginAbility.METADATA -> FeatherIcons.FileText - PluginAbility.AUDIO -> FeatherIcons.Music - PluginAbility.LYRICS -> FeatherIcons.AlignLeft - PluginAbility.SCROBBLE -> FeatherIcons.Activity - }, - contentDescription = stringResource( - Res.string.settings_plugins_plugin_content_description, - ability.displayLabel() - ), - modifier = Modifier.padding(8.dp), - tint = when (ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50) - PluginAbility.AUDIO -> Color(0xFF2196F3) - PluginAbility.LYRICS -> Color(0xFFFFC107) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0) - } - ) - } - - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource( - Res.string.settings_plugins_default_ability_title, - ability.displayLabel() - ), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface - ) - if (selectedPlugin != null) { - Text( - selectedPlugin.name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 4.dp) - ) - } else { - Text( - stringResource(Res.string.settings_plugins_no_selection), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - modifier = Modifier.padding(top = 4.dp) - ) - } - } + Icon( + imageVector = when (ability) { + PluginAbility.METADATA -> FeatherIcons.FileText + PluginAbility.AUDIO -> FeatherIcons.Music + PluginAbility.LYRICS -> FeatherIcons.AlignLeft + PluginAbility.SCROBBLE -> FeatherIcons.Activity + }, + contentDescription = stringResource( + Res.string.settings_plugins_plugin_content_description, + ability.displayLabel() + ), + modifier = Modifier.padding(8.dp), + tint = when (ability) { + PluginAbility.METADATA -> Color(0xFF4CAF50) + PluginAbility.AUDIO -> Color(0xFF2196F3) + PluginAbility.LYRICS -> Color(0xFFFFC107) + PluginAbility.SCROBBLE -> Color(0xFF9C27B0) } + ) + } - AdaptiveDropdownBottomSheet( - items = menuItems, - headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, - header = { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Surface( - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = when (ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) - PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) - PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) - } - ) { - Icon( - imageVector = when (ability) { - PluginAbility.METADATA -> FeatherIcons.FileText - PluginAbility.AUDIO -> FeatherIcons.Music - PluginAbility.LYRICS -> FeatherIcons.AlignLeft - PluginAbility.SCROBBLE -> FeatherIcons.Activity - }, - contentDescription = null, - modifier = Modifier.padding(8.dp), - tint = when (ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50) - PluginAbility.AUDIO -> Color(0xFF2196F3) - PluginAbility.LYRICS -> Color(0xFFFFC107) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0) - } - ) - } - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource( - Res.string.settings_plugins_default_ability_title, - ability.displayLabel() - ), - style = MaterialTheme.typography.titleMedium, - ) - selectedPlugin?.let { - Text( - it.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } - }, - trigger = { onClick -> - Button( - onClick = onClick, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), - contentColor = MaterialTheme.colorScheme.primary - ), - modifier = Modifier.clip(RoundedCornerShape(8.dp)) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = FeatherIcons.Check, - contentDescription = null, - modifier = Modifier.padding(0.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - if (selectedPlugin != null) { - stringResource(Res.string.settings_plugins_action_change) - } else { - stringResource(Res.string.settings_plugins_action_select) - }, - style = MaterialTheme.typography.labelSmall - ) - } - } - }, + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource( + Res.string.settings_plugins_default_ability_title, + ability.displayLabel() + ), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface + ) + if (selectedPlugin != null) { + Text( + selectedPlugin.name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp) + ) + } else { + Text( + stringResource(Res.string.settings_plugins_no_selection), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) ) } } } + + AdaptiveDropdownBottomSheet( + items = menuItems, + headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, + header = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + modifier = Modifier.clip(RoundedCornerShape(8.dp)), + color = when (ability) { + PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) + PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) + PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) + PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) + } + ) { + Icon( + imageVector = when (ability) { + PluginAbility.METADATA -> FeatherIcons.FileText + PluginAbility.AUDIO -> FeatherIcons.Music + PluginAbility.LYRICS -> FeatherIcons.AlignLeft + PluginAbility.SCROBBLE -> FeatherIcons.Activity + }, + contentDescription = null, + modifier = Modifier.padding(8.dp), + tint = when (ability) { + PluginAbility.METADATA -> Color(0xFF4CAF50) + PluginAbility.AUDIO -> Color(0xFF2196F3) + PluginAbility.LYRICS -> Color(0xFFFFC107) + PluginAbility.SCROBBLE -> Color(0xFF9C27B0) + } + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource( + Res.string.settings_plugins_default_ability_title, + ability.displayLabel() + ), + style = MaterialTheme.typography.titleMedium, + ) + selectedPlugin?.let { + Text( + it.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + }, + trigger = { onClick -> + PrimaryButton( + onClick = onClick, + ) { + Icon( + imageVector = FeatherIcons.Check, + contentDescription = null, + modifier = Modifier.padding(0.dp), + ) + Text( + if (selectedPlugin != null) { + stringResource(Res.string.settings_plugins_action_change) + } else { + stringResource(Res.string.settings_plugins_action_select) + }, + style = MaterialTheme.typography.labelSmall + ) + } + }, + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/InstallSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/InstallSection.kt index 6da2529f..e61a86f2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/InstallSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/InstallSection.kt @@ -17,7 +17,6 @@ package dev.krtirtho.spotube.modules.plugin.components -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -28,14 +27,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width 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.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -47,6 +42,9 @@ import compose.icons.FeatherIcons import compose.icons.feathericons.Download import compose.icons.feathericons.Link import compose.icons.feathericons.Upload +import dev.krtirtho.spotube.core.ui.base.Card +import dev.krtirtho.spotube.core.ui.base.OutlineButton +import dev.krtirtho.spotube.core.ui.base.PrimaryButton import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.plugin_action_download @@ -65,11 +63,6 @@ internal fun InstallSection( ) { Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(14.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Column( modifier = Modifier.padding(16.dp), @@ -121,11 +114,9 @@ internal fun InstallSection( shape = RoundedCornerShape(10.dp), textStyle = MaterialTheme.typography.bodySmall ) - Button( + PrimaryButton( onClick = onSubmitUrl, enabled = !isLoadingUrl, - shape = RoundedCornerShape(10.dp), - modifier = Modifier.height(56.dp) ) { if (isLoadingUrl) { CircularProgressIndicator( @@ -146,10 +137,9 @@ internal fun InstallSection( HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) // File picker - OutlinedButton( + OutlineButton( onClick = onPickFile, modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(10.dp) ) { Icon( FeatherIcons.Upload, @@ -162,4 +152,3 @@ internal fun InstallSection( } } } - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt index e9b58dd2..2a31c2c9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt @@ -27,13 +27,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.TextButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -48,6 +44,8 @@ import compose.icons.feathericons.Package import compose.icons.feathericons.Tag import compose.icons.feathericons.Trash2 import compose.icons.feathericons.User +import dev.krtirtho.spotube.core.ui.base.GhostIconButton +import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.modules.plugin.BUILT_IN_PLUGINS import dev.krtirtho.spotube.modules.plugin.PluginAbility import dev.krtirtho.spotube.modules.plugin.PluginEntry @@ -73,24 +71,10 @@ internal fun PluginCard( onLogin: (() -> Unit)? = null, onLogout: (() -> Unit)? = null, ) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(14.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - border = BorderStroke( - width = 1.dp, - color = if (isSelected) - MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) - else - MaterialTheme.colorScheme.outlineVariant - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(14.dp), + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp) ) { @@ -247,7 +231,7 @@ internal fun PluginCard( .padding(horizontal = 6.dp, vertical = 2.dp) ) } else { - IconButton(onClick = onRemove) { + GhostIconButton(onClick = onRemove) { Icon( FeatherIcons.Trash2, contentDescription = stringResource(Res.string.plugin_action_remove), @@ -263,12 +247,11 @@ internal fun PluginCard( else -> null } authAction?.let { (action, label) -> - TextButton(onClick = action) { + OutlineButton(onClick = action) { Text( text = stringResource(label), style = MaterialTheme.typography.labelLarge ) - } } } } @@ -284,4 +267,3 @@ private fun PluginAbility.displayLabel(): String { PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble) } } - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt index 633b7a48..35736f3c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt @@ -24,18 +24,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton -import dev.krtirtho.spotube.core.ui.base.Toggle import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -46,8 +41,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.core.ui.base.OutlineButton +import dev.krtirtho.spotube.core.ui.base.PrimaryButton +import dev.krtirtho.spotube.core.ui.base.Radio +import dev.krtirtho.spotube.core.ui.base.TextField +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.core.ui.base.Toggle import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import spotube.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -107,8 +110,13 @@ internal fun SelectionSettingCard( ) }, trigger = { onClick -> - TextButton(onClick = onClick) { + OutlineButton(onClick = onClick) { Text(optionLabel(selectedOption)) + Icon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) } }, filter = filter, @@ -122,51 +130,47 @@ internal fun SelectionSettingCard( ) if (!isWideLayout && isDialogOpen) { - AlertDialog( + ThemedDialog( onDismissRequest = { isDialogOpen = false }, title = { - Text(dialogTitle) + Text(dialogTitle, style = MaterialTheme.typography.titleLarge) }, - text = { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - options.forEach { option -> - val isSelected = option == selectedOption - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .clickable { - onOptionSelected(option) - isDialogOpen = false - } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - RadioButton( - selected = isSelected, - onClick = { - onOptionSelected(option) - isDialogOpen = false - } - ) - Text( - text = optionLabel(option), - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - }, - confirmButton = { - TextButton(onClick = { isDialogOpen = false }) { + actions = { + OutlineButton(onClick = { isDialogOpen = false }) { Text(stringResource(Res.string.settings_action_close)) } + }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + options.forEach { option -> + val isSelected = option == selectedOption + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { + onOptionSelected(option) + isDialogOpen = false + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Radio( + selected = isSelected, + onClick = { + onOptionSelected(option) + isDialogOpen = false + } + ) + Text( + text = optionLabel(option), + style = MaterialTheme.typography.bodyMedium + ) + } + } } - ) + } } } } @@ -234,39 +238,13 @@ internal fun TextInputSettingCard( val normalizedValue = normalize(draft) val errorMessage = validate(normalizedValue) - AlertDialog( + ThemedDialog( onDismissRequest = { isDialogOpen = false }, title = { - Text(dialogTitle) + Text(dialogTitle, style = MaterialTheme.typography.titleLarge) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - dialogDescription?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = if (placeholder.isNotEmpty()) { - { Text(placeholder) } - } else { - null - }, - isError = errorMessage != null, - supportingText = errorMessage?.let { message -> - { Text(message) } - }, - singleLine = true, - ) - } - }, - confirmButton = { - TextButton( + actions = { + PrimaryButton( onClick = { if (errorMessage == null) { onValueSaved(normalizedValue) @@ -276,13 +254,36 @@ internal fun TextInputSettingCard( ) { Text(stringResource(Res.string.settings_action_save)) } - }, - dismissButton = { - TextButton(onClick = { isDialogOpen = false }) { + OutlineButton(onClick = { isDialogOpen = false }) { Text(stringResource(Res.string.settings_action_cancel)) } + }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + dialogDescription?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = if (placeholder.isNotEmpty()) { + { Text(placeholder) } + } else { + null + }, + isError = errorMessage != null, + supportingText = errorMessage?.let { message -> + { Text(message) } + }, + singleLine = true, + ) } - ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt index fd514df2..7e34554f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt @@ -17,26 +17,11 @@ package dev.krtirtho.spotube.modules.settings.components -import androidx.compose.foundation.BorderStroke -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.RowScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults 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.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.core.ui.base.ListRowTile /** * Reusable setting card item component for consistent styling @@ -48,54 +33,32 @@ fun SettingCardItem( title: String, subtitle: String? = null, icon: (@Composable () -> Unit)? = null, - trailingContent: (@Composable RowScope.() -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, onClick: () -> Unit = {}, modifier: Modifier = Modifier ) { - Card( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onClick, enabled = enabled) - .then(if (!enabled) Modifier.alpha(0.5f) else Modifier), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - border = BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant - ), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (icon != null) { - icon() - } - - Column(modifier = Modifier.weight(1f)) { + ListRowTile( + selected = false, + enabled = enabled, + onClick = onClick, + modifier = modifier, + leading = icon, + title = { + Text( + title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface + ) + }, + subtitle = subtitle?.let { + { Text( - title, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - if (subtitle != null) { - Text( - subtitle, - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - modifier = Modifier.padding(top = 4.dp) - ) - } } - - trailingContent?.invoke(this) - } - } -} \ No newline at end of file + }, + trailing = trailingContent, + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt index ac60082e..3925390f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt @@ -27,19 +27,14 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,6 +48,10 @@ import compose.icons.FeatherIcons import compose.icons.feathericons.Droplet import compose.icons.feathericons.Monitor import spotube.composeapp.generated.resources.* +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.core.ui.base.OutlineButton +import dev.krtirtho.spotube.core.ui.base.PrimaryButton +import dev.krtirtho.spotube.core.ui.base.Radio import dev.krtirtho.spotube.modules.settings.AccentColors import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.Theme @@ -67,41 +66,43 @@ internal fun LazyListScope.appearanceSection( settingsViewModel: SettingsViewModel, ) { settingsSectionHeader(Res.string.settings_section_appearance) - - item { - SelectionSettingCard( - title = stringResource(Res.string.settings_theme_title), - subtitle = stringResource( - Res.string.settings_theme_subtitle_current, - settings.theme.displayLabel() - ), - icon = { - SettingsItemIcon(FeatherIcons.Monitor, stringResource(Res.string.settings_theme_title)) + settingsSectionCard( + items = listOf( + { + SelectionSettingCard( + title = stringResource(Res.string.settings_theme_title), + subtitle = stringResource( + Res.string.settings_theme_subtitle_current, + settings.theme.displayLabel() + ), + icon = { + SettingsItemIcon(FeatherIcons.Monitor, stringResource(Res.string.settings_theme_title)) + }, + selectedOption = settings.theme, + options = Theme.entries, + optionLabel = { it.displayLabel() }, + onOptionSelected = { theme -> + settingsViewModel.updateSettings { + copy(theme = theme) + } + } + ) }, - selectedOption = settings.theme, - options = Theme.entries, - optionLabel = { it.displayLabel() }, - onOptionSelected = { theme -> - settingsViewModel.updateSettings { - copy(theme = theme) - } - } - ) - } - - item { - AccentColorSettingCard( - selectedAccent = settings.accentColor, - icon = { - SettingsItemIcon(FeatherIcons.Droplet, stringResource(Res.string.settings_accent_title)) + { + AccentColorSettingCard( + selectedAccent = settings.accentColor, + icon = { + SettingsItemIcon(FeatherIcons.Droplet, stringResource(Res.string.settings_accent_title)) + }, + onColorSaved = { accent -> + settingsViewModel.updateSettings { + copy(accentColor = accent) + } + } + ) }, - onColorSaved = { accent -> - settingsViewModel.updateSettings { - copy(accentColor = accent) - } - } ) - } + ) } @Composable @@ -155,75 +156,13 @@ private fun AccentColorSettingCard( if (isDialogOpen) { var draftAccent by remember(selectedAccent, isDialogOpen) { mutableStateOf(selectedAccent) } - AlertDialog( + ThemedDialog( onDismissRequest = { isDialogOpen = false }, title = { - Text(stringResource(Res.string.settings_accent_dialog_title)) + Text(stringResource(Res.string.settings_accent_dialog_title), style = MaterialTheme.typography.titleLarge) }, - text = { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 420.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = stringResource(Res.string.settings_accent_dialog_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - AccentThemePreview( - title = stringResource(Res.string.settings_preview_light), - accent = draftAccent.toLightColor(), - background = Color(0xFFFFFFFF), - textColor = Color(0xFF121212), - modifier = Modifier.weight(1f) - ) - AccentThemePreview( - title = stringResource(Res.string.settings_preview_dark), - accent = draftAccent.toDarkColor(), - background = Color(0xFF121212), - textColor = Color(0xFFEDEDED), - modifier = Modifier.weight(1f) - ) - } - - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - AccentColors.entries.forEach { option -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { draftAccent = option } - .padding(horizontal = 4.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - RadioButton( - selected = option == draftAccent, - onClick = { draftAccent = option } - ) - Text( - text = option.displayLabel(), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f) - ) - AccentDualPreview( - lightAccent = option.toLightColor(), - darkAccent = option.toDarkColor(), - ) - } - } - } - } - }, - confirmButton = { - TextButton( + actions = { + PrimaryButton( onClick = { onColorSaved(draftAccent) isDialogOpen = false @@ -231,13 +170,66 @@ private fun AccentColorSettingCard( ) { Text(stringResource(Res.string.settings_action_save)) } - }, - dismissButton = { - TextButton(onClick = { isDialogOpen = false }) { + OutlineButton(onClick = { isDialogOpen = false }) { Text(stringResource(Res.string.settings_action_cancel)) } + }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(Res.string.settings_accent_dialog_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + AccentThemePreview( + title = stringResource(Res.string.settings_preview_light), + accent = draftAccent.toLightColor(), + background = Color(0xFFFFFFFF), + textColor = Color(0xFF121212), + modifier = Modifier.weight(1f) + ) + AccentThemePreview( + title = stringResource(Res.string.settings_preview_dark), + accent = draftAccent.toDarkColor(), + background = Color(0xFF121212), + textColor = Color(0xFFEDEDED), + modifier = Modifier.weight(1f) + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + AccentColors.entries.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { draftAccent = option } + .padding(horizontal = 4.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Radio( + selected = option == draftAccent, + onClick = { draftAccent = option } + ) + Text( + text = option.displayLabel(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + AccentDualPreview( + lightAccent = option.toLightColor(), + darkAccent = option.toDarkColor(), + ) + } + } + } } - ) + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt index e7b210fa..bf9fe9a1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt @@ -19,14 +19,12 @@ package dev.krtirtho.spotube.modules.settings.sections import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import compose.icons.FeatherIcons import compose.icons.feathericons.Folder import compose.icons.feathericons.HardDrive import spotube.composeapp.generated.resources.* +import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.components.SettingCardItem @@ -41,83 +39,84 @@ internal fun LazyListScope.cacheSection( settingsViewModel: SettingsViewModel, ) { settingsSectionHeader(Res.string.settings_section_caching) - - item { - SwitchSettingCard( - title = stringResource(Res.string.settings_enable_music_caching_title), - subtitle = stringResource(Res.string.settings_enable_music_caching_subtitle), - icon = { - SettingsItemIcon( - FeatherIcons.HardDrive, - stringResource(Res.string.settings_enable_music_caching_title) - ) - }, - checked = settings.enableMusicCaching, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(enableMusicCaching = enabled) - } - } - ) - } - - item { - CacheFolderSettingCard( - enabled = settings.enableMusicCaching, - folder = settings.cacheFolder, - onFolderSelected = { folder -> - settingsViewModel.updateSettings { - copy(cacheFolder = folder) - } - } - ) - } - - item { - val error_whole_number = stringResource(Res.string.settings_error_whole_number) - val error_cache_range = stringResource(Res.string.settings_error_cache_size_range) - - TextInputSettingCard( - enabled = settings.enableMusicCaching, - title = stringResource(Res.string.settings_cache_size_limit_title), - subtitle = when { - settings.cacheSizeLimitMB <= 0L -> stringResource(Res.string.settings_cache_size_limit_unlimited) - else -> stringResource( - Res.string.settings_cache_size_limit_current, - settings.cacheSizeLimitMB - ) - }, - icon = { - SettingsItemIcon( - FeatherIcons.HardDrive, - stringResource(Res.string.settings_cache_size_limit_title) - ) - }, - value = when { - settings.cacheSizeLimitMB <= 0L -> "" - else -> settings.cacheSizeLimitMB.toString() - }, - dialogDescription = stringResource(Res.string.settings_cache_size_limit_description), - placeholder = stringResource(Res.string.settings_cache_size_limit_placeholder), - normalize = { it.trim() }, - validate = { value -> - if (value.isBlank()) null - else { - val mb = value.toLongOrNull() - when { - mb == null -> error_whole_number - mb < 0L -> error_cache_range - else -> null + settingsSectionCard( + items = listOf( + { + SwitchSettingCard( + title = stringResource(Res.string.settings_enable_music_caching_title), + subtitle = stringResource(Res.string.settings_enable_music_caching_subtitle), + icon = { + SettingsItemIcon( + FeatherIcons.HardDrive, + stringResource(Res.string.settings_enable_music_caching_title) + ) + }, + checked = settings.enableMusicCaching, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(enableMusicCaching = enabled) + } } - } + ) + }, + { + CacheFolderSettingCard( + enabled = settings.enableMusicCaching, + folder = settings.cacheFolder, + onFolderSelected = { folder -> + settingsViewModel.updateSettings { + copy(cacheFolder = folder) + } + } + ) + }, + { + val error_whole_number = stringResource(Res.string.settings_error_whole_number) + val error_cache_range = stringResource(Res.string.settings_error_cache_size_range) + + TextInputSettingCard( + enabled = settings.enableMusicCaching, + title = stringResource(Res.string.settings_cache_size_limit_title), + subtitle = when { + settings.cacheSizeLimitMB <= 0L -> stringResource(Res.string.settings_cache_size_limit_unlimited) + else -> stringResource( + Res.string.settings_cache_size_limit_current, + settings.cacheSizeLimitMB + ) + }, + icon = { + SettingsItemIcon( + FeatherIcons.HardDrive, + stringResource(Res.string.settings_cache_size_limit_title) + ) + }, + value = when { + settings.cacheSizeLimitMB <= 0L -> "" + else -> settings.cacheSizeLimitMB.toString() + }, + dialogDescription = stringResource(Res.string.settings_cache_size_limit_description), + placeholder = stringResource(Res.string.settings_cache_size_limit_placeholder), + normalize = { it.trim() }, + validate = { value -> + if (value.isBlank()) null + else { + val mb = value.toLongOrNull() + when { + mb == null -> error_whole_number + mb < 0L -> error_cache_range + else -> null + } + } + }, + onValueSaved = { value -> + settingsViewModel.updateSettings { + copy(cacheSizeLimitMB = value.toLongOrNull()?.coerceAtLeast(0L) ?: 0L) + } + } + ) }, - onValueSaved = { value -> - settingsViewModel.updateSettings { - copy(cacheSizeLimitMB = value.toLongOrNull()?.coerceAtLeast(0L) ?: 0L) - } - } ) - } + ) } @Composable @@ -147,7 +146,7 @@ private fun CacheFolderSettingCard( ) }, trailingContent = { - IconButton( + GhostIconButton( onClick = { pickerLauncher.launch() }, enabled = enabled, ) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt index ed261b5d..3026b168 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt @@ -32,43 +32,45 @@ internal fun LazyListScope.desktopSection( settingsViewModel: SettingsViewModel, ) { settingsSectionHeader(Res.string.settings_section_desktop) - - item { - SwitchSettingCard( - title = stringResource(Res.string.settings_desktop_minimize_title), - subtitle = stringResource(Res.string.settings_desktop_minimize_subtitle), - icon = { - SettingsItemIcon( - FeatherIcons.Minimize2, - stringResource(Res.string.settings_desktop_minimize_title) + settingsSectionCard( + items = listOf( + { + SwitchSettingCard( + title = stringResource(Res.string.settings_desktop_minimize_title), + subtitle = stringResource(Res.string.settings_desktop_minimize_subtitle), + icon = { + SettingsItemIcon( + FeatherIcons.Minimize2, + stringResource(Res.string.settings_desktop_minimize_title) + ) + }, + checked = settings.minimizeToTray, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(minimizeToTray = enabled) + } + } ) }, - checked = settings.minimizeToTray, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(minimizeToTray = enabled) - } - } - ) - } - - item { - SwitchSettingCard( - title = stringResource(Res.string.settings_desktop_discord_title), - subtitle = stringResource(Res.string.settings_desktop_discord_subtitle), - icon = { - SettingsItemIcon( - FeatherIcons.Activity, - stringResource(Res.string.settings_desktop_discord_title) + { + SwitchSettingCard( + title = stringResource(Res.string.settings_desktop_discord_title), + subtitle = stringResource(Res.string.settings_desktop_discord_subtitle), + icon = { + SettingsItemIcon( + FeatherIcons.Activity, + stringResource(Res.string.settings_desktop_discord_title) + ) + }, + checked = settings.discordRichPresence, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(discordRichPresence = enabled) + } + } ) }, - checked = settings.discordRichPresence, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(discordRichPresence = enabled) - } - } ) - } + ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt index c4ae1a8c..c9b26911 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt @@ -26,12 +26,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -49,6 +46,10 @@ import compose.icons.feathericons.PlusSquare import compose.icons.feathericons.Sliders import compose.icons.feathericons.Trash2 import spotube.composeapp.generated.resources.* +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.core.ui.base.GhostIconButton +import dev.krtirtho.spotube.core.ui.base.OutlineButton +import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.components.SettingCardItem @@ -68,73 +69,73 @@ internal fun LazyListScope.downloadsSection( ) settingsSectionHeader(Res.string.settings_section_downloads) - - item { - DownloadFolderSettingCard( - folder = settings.overloadedDownloadFolder, - onFolderSelected = { folder -> - settingsViewModel.updateSettings { - copy(overloadedDownloadFolder = folder) - } - } - ) - } - - item { - LocalMediaFoldersSettingCard( - folders = settings.localMediaFolders, - onFoldersSaved = { folders -> - settingsViewModel.updateSettings { - copy(localMediaFolders = folders) - } - } - ) - } - - item { - SelectionSettingCard( - title = stringResource(Res.string.settings_download_format_title), - subtitle = stringResource( - Res.string.settings_download_format_subtitle_current, - settings.downloadMusicFormat.displayLabel() - ), - icon = { - SettingsItemIcon(FeatherIcons.Disc, stringResource(Res.string.settings_download_format_title)) + settingsSectionCard( + items = listOf( + { + DownloadFolderSettingCard( + folder = settings.overloadedDownloadFolder, + onFolderSelected = { folder -> + settingsViewModel.updateSettings { + copy(overloadedDownloadFolder = folder) + } + } + ) }, - selectedOption = settings.downloadMusicFormat, - options = downloadFormats, - optionLabel = { it.displayLabel() }, - onOptionSelected = { format -> - settingsViewModel.updateSettings { - copy( - downloadMusicFormat = format, - downloadMusicQuality = format.resolveQuality(downloadMusicQuality), - ) - } - } - ) - } - - item { - SelectionSettingCard( - title = stringResource(Res.string.settings_download_quality_title), - subtitle = stringResource( - Res.string.settings_subtitle_current, - settings.downloadMusicQuality.displayLabel() - ), - icon = { - SettingsItemIcon(FeatherIcons.Sliders, stringResource(Res.string.settings_download_quality_title)) + { + LocalMediaFoldersSettingCard( + folders = settings.localMediaFolders, + onFoldersSaved = { folders -> + settingsViewModel.updateSettings { + copy(localMediaFolders = folders) + } + } + ) + }, + { + SelectionSettingCard( + title = stringResource(Res.string.settings_download_format_title), + subtitle = stringResource( + Res.string.settings_download_format_subtitle_current, + settings.downloadMusicFormat.displayLabel() + ), + icon = { + SettingsItemIcon(FeatherIcons.Disc, stringResource(Res.string.settings_download_format_title)) + }, + selectedOption = settings.downloadMusicFormat, + options = downloadFormats, + optionLabel = { it.displayLabel() }, + onOptionSelected = { format -> + settingsViewModel.updateSettings { + copy( + downloadMusicFormat = format, + downloadMusicQuality = format.resolveQuality(downloadMusicQuality), + ) + } + } + ) + }, + { + SelectionSettingCard( + title = stringResource(Res.string.settings_download_quality_title), + subtitle = stringResource( + Res.string.settings_subtitle_current, + settings.downloadMusicQuality.displayLabel() + ), + icon = { + SettingsItemIcon(FeatherIcons.Sliders, stringResource(Res.string.settings_download_quality_title)) + }, + selectedOption = settings.downloadMusicQuality, + options = downloadQualities, + optionLabel = { it.displayLabel() }, + onOptionSelected = { quality -> + settingsViewModel.updateSettings { + copy(downloadMusicQuality = quality) + } + } + ) }, - selectedOption = settings.downloadMusicQuality, - options = downloadQualities, - optionLabel = { it.displayLabel() }, - onOptionSelected = { quality -> - settingsViewModel.updateSettings { - copy(downloadMusicQuality = quality) - } - } ) - } + ) } @Composable @@ -160,7 +161,7 @@ private fun DownloadFolderSettingCard( SettingsItemIcon(FeatherIcons.Folder, stringResource(Res.string.settings_download_folder_title)) }, trailingContent = { - IconButton(onClick = { pickerLauncher.launch() }) { + GhostIconButton(onClick = { pickerLauncher.launch() }) { Icon( FeatherIcons.Folder, contentDescription = stringResource(Res.string.settings_download_folder_title) @@ -199,7 +200,7 @@ private fun LocalMediaFoldersSettingCard( ) }, trailingContent = { - TextButton(onClick = { isDialogOpen = true }) { + OutlineButton(onClick = { isDialogOpen = true }) { Text(stringResource(Res.string.settings_local_media_folders_manage)) } }, @@ -235,76 +236,16 @@ private fun LocalMediaFoldersDialog( } } - AlertDialog( + ThemedDialog( onDismissRequest = onDismiss, title = { - Text(stringResource(Res.string.settings_local_media_folders_title)) + Text( + text = stringResource(Res.string.settings_local_media_folders_title), + style = MaterialTheme.typography.titleLarge, + ) }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - Text( - text = stringResource(Res.string.settings_local_media_folders_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - TextButton(onClick = { pickerLauncher.launch() }) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(FeatherIcons.PlusSquare, contentDescription = null) - Text(stringResource(Res.string.settings_local_media_folders_add_action)) - } - } - - if (draftFolders.isEmpty()) { - Box(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { - Text( - text = stringResource(Res.string.settings_local_media_folders_none_added), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } else { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - draftFolders.forEachIndexed { index, folder -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = folder, - modifier = Modifier.weight(1f), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - ) - IconButton( - onClick = { - if (index in draftFolders.indices) { - draftFolders.removeAt(index) - } - } - ) { - Icon( - imageVector = FeatherIcons.Trash2, - contentDescription = stringResource( - Res.string.settings_local_media_folders_remove_action, - ), - ) - } - } - } - } - } - } - }, - confirmButton = { - TextButton( + actions = { + PrimaryButton( onClick = { onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct()) onDismiss() @@ -312,12 +253,69 @@ private fun LocalMediaFoldersDialog( ) { Text(stringResource(Res.string.settings_action_save)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { + OutlineButton(onClick = onDismiss) { Text(stringResource(Res.string.settings_action_cancel)) } + }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(Res.string.settings_local_media_folders_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlineButton(onClick = { pickerLauncher.launch() }) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(FeatherIcons.PlusSquare, contentDescription = null) + Text(stringResource(Res.string.settings_local_media_folders_add_action)) + } + } + + if (draftFolders.isEmpty()) { + Box(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Text( + text = stringResource(Res.string.settings_local_media_folders_none_added), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + draftFolders.forEachIndexed { index, folder -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = folder, + modifier = Modifier.weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + GhostIconButton( + onClick = { + if (index in draftFolders.indices) { + draftFolders.removeAt(index) + } + } + ) { + Icon( + imageVector = FeatherIcons.Trash2, + contentDescription = stringResource( + Res.string.settings_local_media_folders_remove_action, + ), + ) + } + } + } + } + } } - ) + } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt index 06b7dabb..db2460cb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt @@ -34,59 +34,61 @@ internal fun LazyListScope.languageRegionSection( settingsViewModel: SettingsViewModel, ) { settingsSectionHeader(Res.string.settings_section_language_region) - - item { - SelectionSettingCard( - title = stringResource(Res.string.settings_language_title), - subtitle = stringResource( - Res.string.settings_language_subtitle_current, - settings.language.displayName - ), - icon = { - SettingsItemIcon( - FeatherIcons.Globe, - stringResource(Res.string.settings_language_title) + settingsSectionCard( + items = listOf( + { + SelectionSettingCard( + title = stringResource(Res.string.settings_language_title), + subtitle = stringResource( + Res.string.settings_language_subtitle_current, + settings.language.displayName + ), + icon = { + SettingsItemIcon( + FeatherIcons.Globe, + stringResource(Res.string.settings_language_title) + ) + }, + selectedOption = settings.language, + options = SupportedLanguages.entries, + optionLabel = { + stringResource(Res.string.settings_option_name_and_code, it.displayName, it.locale) + }, + onOptionSelected = { value -> + settingsViewModel.updateSettings { + copy(language = value) + } + }, + filter = { item, query -> item.label.contains(query, ignoreCase = true) }, ) }, - selectedOption = settings.language, - options = SupportedLanguages.entries, - optionLabel = { - stringResource(Res.string.settings_option_name_and_code, it.displayName, it.locale) - }, - onOptionSelected = { value -> - settingsViewModel.updateSettings { - copy(language = value) - } - }, - filter = { item, query -> item.label.contains(query, ignoreCase = true) }, - ) - } - - item { - SelectionSettingCard( - title = stringResource(Res.string.settings_country_title), - subtitle = stringResource( - Res.string.settings_country_subtitle_current, - settings.country.displayName - ), - icon = { - SettingsItemIcon( - FeatherIcons.MapPin, - stringResource(Res.string.settings_country_title) + { + SelectionSettingCard( + title = stringResource(Res.string.settings_country_title), + subtitle = stringResource( + Res.string.settings_country_subtitle_current, + settings.country.displayName + ), + icon = { + SettingsItemIcon( + FeatherIcons.MapPin, + stringResource(Res.string.settings_country_title) + ) + }, + selectedOption = settings.country, + options = CountryCode.entries, + optionLabel = { + stringResource(Res.string.settings_option_name_and_code, it.displayName, it.code) + }, + onOptionSelected = { value -> + settingsViewModel.updateSettings { + copy(country = value) + } + }, + filter = { item, query -> item.label.contains(query, ignoreCase = true) }, ) }, - selectedOption = settings.country, - options = CountryCode.entries, - optionLabel = { - stringResource(Res.string.settings_option_name_and_code, it.displayName, it.code) - }, - onOptionSelected = { value -> - settingsViewModel.updateSettings { - copy(country = value) - } - }, - filter = { item, query -> item.label.contains(query, ignoreCase = true) }, ) - } + ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt index f0cbeb4f..74e9daf1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt @@ -43,118 +43,117 @@ internal fun LazyListScope.playbackSection( ) settingsSectionHeader(Res.string.settings_section_playback) - - item { - SelectionSettingCard( - title = stringResource(Res.string.settings_streaming_format_title), - subtitle = stringResource( - Res.string.settings_streaming_format_subtitle_current, - settings.streamingMusicFormat.displayLabel() - ), - icon = { - SettingsItemIcon(FeatherIcons.Radio, stringResource(Res.string.settings_streaming_format_title)) - }, - selectedOption = settings.streamingMusicFormat, - options = streamingFormats, - optionLabel = { it.displayLabel() }, - onOptionSelected = { format -> - settingsViewModel.updateSettings { - copy( - streamingMusicFormat = format, - streamingMusicQuality = format.resolveQuality(streamingMusicQuality), - ) - } - } - ) - } - - item { - SelectionSettingCard( - title = stringResource(Res.string.settings_streaming_quality_title), - subtitle = stringResource( - Res.string.settings_subtitle_current, - settings.streamingMusicQuality.displayLabel() - ), - icon = { - SettingsItemIcon(FeatherIcons.Sliders, stringResource(Res.string.settings_streaming_quality_title)) - }, - selectedOption = settings.streamingMusicQuality, - options = streamingQualities, - optionLabel = { it.displayLabel() }, - onOptionSelected = { quality -> - settingsViewModel.updateSettings { - copy(streamingMusicQuality = quality) - } - } - ) - } - - item { - SwitchSettingCard( - title = stringResource(Res.string.settings_enable_endless_playback_title), - subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle), - icon = { - SettingsItemIcon( - FeatherIcons.Repeat, - stringResource(Res.string.settings_enable_endless_playback_title) + settingsSectionCard( + items = listOf( + { + SelectionSettingCard( + title = stringResource(Res.string.settings_streaming_format_title), + subtitle = stringResource( + Res.string.settings_streaming_format_subtitle_current, + settings.streamingMusicFormat.displayLabel() + ), + icon = { + SettingsItemIcon(FeatherIcons.Radio, stringResource(Res.string.settings_streaming_format_title)) + }, + selectedOption = settings.streamingMusicFormat, + options = streamingFormats, + optionLabel = { it.displayLabel() }, + onOptionSelected = { format -> + settingsViewModel.updateSettings { + copy( + streamingMusicFormat = format, + streamingMusicQuality = format.resolveQuality(streamingMusicQuality), + ) + } + } ) }, - checked = settings.enableEndlessPlayback, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(enableEndlessPlayback = enabled) - } - } - ) - } - - item { - SwitchSettingCard( - title = stringResource(Res.string.settings_enable_connect_title), - subtitle = stringResource(Res.string.settings_enable_connect_subtitle), - icon = { - SettingsItemIcon(FeatherIcons.Cast, stringResource(Res.string.settings_enable_connect_title)) + { + SelectionSettingCard( + title = stringResource(Res.string.settings_streaming_quality_title), + subtitle = stringResource( + Res.string.settings_subtitle_current, + settings.streamingMusicQuality.displayLabel() + ), + icon = { + SettingsItemIcon(FeatherIcons.Sliders, stringResource(Res.string.settings_streaming_quality_title)) + }, + selectedOption = settings.streamingMusicQuality, + options = streamingQualities, + optionLabel = { it.displayLabel() }, + onOptionSelected = { quality -> + settingsViewModel.updateSettings { + copy(streamingMusicQuality = quality) + } + } + ) }, - checked = settings.enableConnect, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(enableConnect = enabled) - } - } - ) - } - - item { - val error_whole_number = stringResource(Res.string.settings_error_whole_number) - val error_port_range = stringResource(Res.string.settings_error_port_range) - - TextInputSettingCard( - title = stringResource(Res.string.settings_playback_port_title), - subtitle = stringResource( - Res.string.settings_playback_port_subtitle_current, - settings.playbackProxyServerPort - ), - icon = { - SettingsItemIcon(FeatherIcons.Server, stringResource(Res.string.settings_playback_port_title)) + { + SwitchSettingCard( + title = stringResource(Res.string.settings_enable_endless_playback_title), + subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle), + icon = { + SettingsItemIcon( + FeatherIcons.Repeat, + stringResource(Res.string.settings_enable_endless_playback_title) + ) + }, + checked = settings.enableEndlessPlayback, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(enableEndlessPlayback = enabled) + } + } + ) }, - value = settings.playbackProxyServerPort.toString(), - dialogDescription = stringResource(Res.string.settings_playback_port_description), - placeholder = stringResource(Res.string.settings_playback_port_placeholder), - normalize = { it.trim() }, - validate = { value -> - val port = value.toIntOrNull() - when { - port == null -> error_whole_number - port !in 1..65535 -> error_port_range - else -> null - } + { + SwitchSettingCard( + title = stringResource(Res.string.settings_enable_connect_title), + subtitle = stringResource(Res.string.settings_enable_connect_subtitle), + icon = { + SettingsItemIcon(FeatherIcons.Cast, stringResource(Res.string.settings_enable_connect_title)) + }, + checked = settings.enableConnect, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(enableConnect = enabled) + } + } + ) + }, + { + val error_whole_number = stringResource(Res.string.settings_error_whole_number) + val error_port_range = stringResource(Res.string.settings_error_port_range) + + TextInputSettingCard( + title = stringResource(Res.string.settings_playback_port_title), + subtitle = stringResource( + Res.string.settings_playback_port_subtitle_current, + settings.playbackProxyServerPort + ), + icon = { + SettingsItemIcon(FeatherIcons.Server, stringResource(Res.string.settings_playback_port_title)) + }, + value = settings.playbackProxyServerPort.toString(), + dialogDescription = stringResource(Res.string.settings_playback_port_description), + placeholder = stringResource(Res.string.settings_playback_port_placeholder), + normalize = { it.trim() }, + validate = { value -> + val port = value.toIntOrNull() + when { + port == null -> error_whole_number + port !in 1..65535 -> error_port_range + else -> null + } + }, + onValueSaved = { value -> + settingsViewModel.updateSettings { + copy(playbackProxyServerPort = value.toInt()) + } + } + ) }, - onValueSaved = { value -> - settingsViewModel.updateSettings { - copy(playbackProxyServerPort = value.toInt()) - } - } ) - } + ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt index 32aa87cc..12868d50 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt @@ -17,19 +17,12 @@ package dev.krtirtho.spotube.modules.settings.sections -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.Package @@ -42,43 +35,30 @@ import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.pluginsSection( navigatorCommands: NavigationCommands ) { - item { - Text( - stringResource(Res.string.settings_section_plugins), - style = MaterialTheme.typography.labelMedium, - color = Color.Gray, - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp, vertical = 8.dp) - ) - } - item { - SettingCardItem( - title = stringResource(Res.string.settings_plugins_manage_title), - subtitle = stringResource(Res.string.settings_plugins_manage_subtitle), - icon = { - Surface( - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = Color(0xFFFF9800).copy(alpha = 0.1f) - ) { - Icon( - imageVector = FeatherIcons.Package, - contentDescription = stringResource(Res.string.settings_section_plugins), - modifier = Modifier.padding(8.dp), - tint = Color(0xFFFF9800) + settingsSectionHeader(Res.string.settings_section_plugins) + settingsSectionCard( + items = listOf { + SettingCardItem( + title = stringResource(Res.string.settings_plugins_manage_title), + subtitle = stringResource(Res.string.settings_plugins_manage_subtitle), + icon = { + SettingsItemIcon( + FeatherIcons.Package, + stringResource(Res.string.settings_section_plugins), + Color(0xFFFF9800) ) + }, + trailingContent = { + Icon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = stringResource(Res.string.settings_plugins_manage_title), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = { + navigatorCommands.navigateTo(Routes.Plugins) } - }, - trailingContent = { - Icon( - imageVector = FeatherIcons.ChevronRight, - contentDescription = stringResource(Res.string.settings_plugins_manage_title), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - onClick = { - navigatorCommands.navigateTo(Routes.Plugins) - } - ) - } + ) + } + ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SettingsSectionSupport.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SettingsSectionSupport.kt index 6caea3ee..1776e14f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SettingsSectionSupport.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/SettingsSectionSupport.kt @@ -18,10 +18,12 @@ package dev.krtirtho.spotube.modules.settings.sections import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -35,6 +37,7 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource +import dev.krtirtho.spotube.core.ui.base.Card private val standardLossyQualities = listOf( AudioQuality.Lossy(bitrate = 44_000), @@ -172,3 +175,30 @@ internal fun normalizePath(path: String): String { return path.trim().trimEnd('/', '\\') } +internal fun LazyListScope.settingsSectionCard( + items: List<@Composable () -> Unit>, +) { + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 4.dp) + ) { + items.forEachIndexed { index, content -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + content() + } + } + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt index 708d0afd..4d0a0c26 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt @@ -33,24 +33,25 @@ internal fun LazyListScope.updatesSection( settingsViewModel: SettingsViewModel, ) { settingsSectionHeader(Res.string.settings_section_updates) - - item { - SwitchSettingCard( - title = stringResource(Res.string.settings_updates_auto_check_title), - subtitle = stringResource(Res.string.settings_updates_auto_check_subtitle), - icon = { - SettingsItemIcon( - FeatherIcons.RefreshCw, - stringResource(Res.string.settings_updates_auto_check_title) - ) - }, - checked = settings.autoCheckForUpdates, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(autoCheckForUpdates = enabled) + settingsSectionCard( + items = listOf { + SwitchSettingCard( + title = stringResource(Res.string.settings_updates_auto_check_title), + subtitle = stringResource(Res.string.settings_updates_auto_check_subtitle), + icon = { + SettingsItemIcon( + FeatherIcons.RefreshCw, + stringResource(Res.string.settings_updates_auto_check_title) + ) + }, + checked = settings.autoCheckForUpdates, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(autoCheckForUpdates = enabled) + } } - } - ) - } + ) + } + ) }