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)
+ }
}
- }
- )
- }
+ )
+ }
+ )
}