feat: implement settings section cards for updates, desktop, language, caching, and plugins

This commit is contained in:
Kingkor Roy Tirtho 2026-07-16 01:28:48 +06:00
parent a590fa59f9
commit 9d79d055ba
19 changed files with 1562 additions and 1140 deletions

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
}
}
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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)),
)
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}
}
}
}

View File

@ -51,6 +51,8 @@ data class BaseUITheme(
val toggle: ToggleTheme, val toggle: ToggleTheme,
val card: CardTheme, val card: CardTheme,
val listRowTile: ListRowTheme, val listRowTile: ListRowTheme,
val dialog: DialogTheme,
val dropdownMenu: DropdownMenuTheme,
) { ) {
@Stable @Stable
@ -159,6 +161,27 @@ data class BaseUITheme(
val shadowElevation: Dp, 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( data class SliderTheme(
val trackActiveColor: InteractionState<Color>, val trackActiveColor: InteractionState<Color>,
val trackInactiveColor: InteractionState<Color>, val trackInactiveColor: InteractionState<Color>,
@ -977,6 +1000,47 @@ fun rememberBaseUITheme(): BaseUITheme {
disabled = scheme.onSurface.copy(alpha = 0.38f), 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),
),
) )
} }

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -64,6 +65,9 @@ import androidx.compose.ui.unit.dp
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.Check import compose.icons.feathericons.Check
import dev.krtirtho.spotube.core.ui.base.TextField 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
@ -105,10 +109,9 @@ fun AdaptiveDropdownBottomSheet(
trigger { expanded = true } trigger { expanded = true }
if (isLargeScreen) { if (isLargeScreen) {
ShadcnDropdownMenu( DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = { expanded = false }, onDismissRequest = { expanded = false },
minWidth = menuMinWidth,
) { ) {
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) { if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
header() header()
@ -137,28 +140,24 @@ fun AdaptiveDropdownBottomSheet(
) )
} }
val hasSelection = items.any { it.selected } items.forEach { item ->
if (query.isNotBlank() && filter != null && !filter(item, query)) {
items return@forEach
.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,
)
} }
if (item.dividerBefore) {
DropdownMenuDivider()
}
DropdownMenuItem(
text = item.label,
onClick = {
item.onClick()
expanded = false
},
enabled = item.enabled,
selected = item.selected,
leadingIcon = item.icon,
)
}
} }
} else { } else {
if (expanded) { 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun AdaptiveBottomSheetContent( private fun AdaptiveBottomSheetContent(

View File

@ -17,7 +17,6 @@
package dev.krtirtho.spotube.modules.plugin package dev.krtirtho.spotube.modules.plugin
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape 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.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -64,6 +59,8 @@ import compose.icons.feathericons.ExternalLink
import compose.icons.feathericons.FileText import compose.icons.feathericons.FileText
import compose.icons.feathericons.Music import compose.icons.feathericons.Music
import compose.icons.feathericons.Package 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.PlatformType
import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem
@ -219,23 +216,41 @@ fun PluginScreen(
} }
// ── Default ability plugin selectors ───────────────── // ── Default ability plugin selectors ─────────────────
items(PluginAbility.entries.size) { index -> item {
val ability = PluginAbility.entries[index] Card(
val selectedPlugin = state.selectedPlugins[ability] modifier = Modifier
DefaultAbilityPluginSelector( .fillMaxWidth()
ability = ability, .padding(vertical = 4.dp),
selectedPlugin = selectedPlugin, ) {
state = when (ability) { Column(
PluginAbility.METADATA -> pluginManager.metadataPlugins modifier = Modifier
PluginAbility.AUDIO -> pluginManager.audioPlugins .fillMaxWidth()
PluginAbility.LYRICS -> pluginManager.lyricsPlugins .padding(top = 4.dp, bottom = 4.dp)
PluginAbility.SCROBBLE -> pluginManager.scrobblePlugins ) {
}, PluginAbility.entries.forEachIndexed { index, ability ->
onSelected = { plugin -> if (index > 0) {
pluginManager.setSelectedPlugin(ability, plugin) HorizontalDivider(
}, color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
onManagePlugins = { } )
) }
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()) { if (state.plugins.isEmpty()) {
@ -294,66 +309,85 @@ fun PluginScreen(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
) )
} }
items(state.plugins) { plugin -> item {
val isSelected = state.selectedPlugins.containsValue(plugin) Card(
val selectedAbility = state.selectedPlugins modifier = Modifier
.entries .fillMaxWidth()
.firstOrNull { (_, selectedPlugin) -> selectedPlugin.id == plugin.id } .padding(vertical = 4.dp),
?.key ) {
val selectedService = selectedAbility?.let { ability -> Column(
activeServices?.get(ability) 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) { var requiresAuth by remember(plugin.id, selectedService) {
mutableStateOf(false) mutableStateOf(false)
} }
var isLoggedIn by remember(plugin.id, selectedService) { var isLoggedIn by remember(plugin.id, selectedService) {
mutableStateOf(false) mutableStateOf(false)
} }
LaunchedEffect(plugin.id, selectedService) { LaunchedEffect(plugin.id, selectedService) {
requiresAuth = false requiresAuth = false
isLoggedIn = false isLoggedIn = false
val service = selectedService ?: return@LaunchedEffect val service = selectedService ?: return@LaunchedEffect
service.use { service.use {
val pluginRequiresAuth = coreAPI.requiresAuthentication val pluginRequiresAuth = coreAPI.requiresAuthentication
requiresAuth = pluginRequiresAuth requiresAuth = pluginRequiresAuth
if (!pluginRequiresAuth) return@use if (!pluginRequiresAuth) return@use
coreAPI.loggedInFlow.collect { loggedIn -> coreAPI.loggedInFlow.collect { loggedIn ->
isLoggedIn = 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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Card( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp), verticalAlignment = Alignment.CenterVertically,
colors = CardDefaults.cardColors( horizontalArrangement = Arrangement.spacedBy(12.dp)
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
}
)
) { ) {
Column( Surface(
modifier = Modifier modifier = Modifier.clip(RoundedCornerShape(8.dp)),
.fillMaxWidth() color = when (ability) {
.padding(12.dp) 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( Icon(
modifier = Modifier.fillMaxWidth(), imageVector = when (ability) {
horizontalArrangement = Arrangement.SpaceBetween, PluginAbility.METADATA -> FeatherIcons.FileText
verticalAlignment = Alignment.CenterVertically PluginAbility.AUDIO -> FeatherIcons.Music
) { PluginAbility.LYRICS -> FeatherIcons.AlignLeft
Row( PluginAbility.SCROBBLE -> FeatherIcons.Activity
modifier = Modifier.weight(1f), },
verticalAlignment = Alignment.CenterVertically, contentDescription = stringResource(
horizontalArrangement = Arrangement.spacedBy(12.dp) Res.string.settings_plugins_plugin_content_description,
) { ability.displayLabel()
Surface( ),
modifier = Modifier.clip(RoundedCornerShape(8.dp)), modifier = Modifier.padding(8.dp),
color = when (ability) { tint = when (ability) {
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) PluginAbility.METADATA -> Color(0xFF4CAF50)
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) PluginAbility.AUDIO -> Color(0xFF2196F3)
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) PluginAbility.LYRICS -> Color(0xFFFFC107)
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
}
) {
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)
)
}
}
} }
)
}
AdaptiveDropdownBottomSheet( Column(modifier = Modifier.weight(1f)) {
items = menuItems, Text(
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, stringResource(
header = { Res.string.settings_plugins_default_ability_title,
Row( ability.displayLabel()
modifier = Modifier ),
.fillMaxWidth() style = MaterialTheme.typography.labelLarge,
.padding(horizontal = 16.dp, vertical = 12.dp), color = MaterialTheme.colorScheme.onSurface
verticalAlignment = Alignment.CenterVertically, )
horizontalArrangement = Arrangement.spacedBy(12.dp), if (selectedPlugin != null) {
) { Text(
Surface( selectedPlugin.name,
modifier = Modifier.clip(RoundedCornerShape(8.dp)), style = MaterialTheme.typography.bodySmall,
color = when (ability) { color = MaterialTheme.colorScheme.primary,
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) modifier = Modifier.padding(top = 4.dp)
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) )
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) } else {
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) Text(
} stringResource(Res.string.settings_plugins_no_selection),
) { style = MaterialTheme.typography.bodySmall,
Icon( color = MaterialTheme.colorScheme.onSurfaceVariant,
imageVector = when (ability) { modifier = Modifier.padding(top = 4.dp)
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
)
}
}
},
) )
} }
} }
} }
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
)
}
},
)
} }
} }

View File

@ -17,7 +17,6 @@
package dev.krtirtho.spotube.modules.plugin.components package dev.krtirtho.spotube.modules.plugin.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape 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.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -47,6 +42,9 @@ import compose.icons.FeatherIcons
import compose.icons.feathericons.Download import compose.icons.feathericons.Download
import compose.icons.feathericons.Link import compose.icons.feathericons.Link
import compose.icons.feathericons.Upload 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 org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_action_download import spotube.composeapp.generated.resources.plugin_action_download
@ -65,11 +63,6 @@ internal fun InstallSection(
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), 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( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
@ -121,11 +114,9 @@ internal fun InstallSection(
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
textStyle = MaterialTheme.typography.bodySmall textStyle = MaterialTheme.typography.bodySmall
) )
Button( PrimaryButton(
onClick = onSubmitUrl, onClick = onSubmitUrl,
enabled = !isLoadingUrl, enabled = !isLoadingUrl,
shape = RoundedCornerShape(10.dp),
modifier = Modifier.height(56.dp)
) { ) {
if (isLoadingUrl) { if (isLoadingUrl) {
CircularProgressIndicator( CircularProgressIndicator(
@ -146,10 +137,9 @@ internal fun InstallSection(
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
// File picker // File picker
OutlinedButton( OutlineButton(
onClick = onPickFile, onClick = onPickFile,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp)
) { ) {
Icon( Icon(
FeatherIcons.Upload, FeatherIcons.Upload,
@ -162,4 +152,3 @@ internal fun InstallSection(
} }
} }
} }

View File

@ -27,13 +27,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape 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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.TextButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -48,6 +44,8 @@ import compose.icons.feathericons.Package
import compose.icons.feathericons.Tag import compose.icons.feathericons.Tag
import compose.icons.feathericons.Trash2 import compose.icons.feathericons.Trash2
import compose.icons.feathericons.User 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.BUILT_IN_PLUGINS
import dev.krtirtho.spotube.modules.plugin.PluginAbility import dev.krtirtho.spotube.modules.plugin.PluginAbility
import dev.krtirtho.spotube.modules.plugin.PluginEntry import dev.krtirtho.spotube.modules.plugin.PluginEntry
@ -73,24 +71,10 @@ internal fun PluginCard(
onLogin: (() -> Unit)? = null, onLogin: (() -> Unit)? = null,
onLogout: (() -> Unit)? = null, onLogout: (() -> Unit)? = null,
) { ) {
Card( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
shape = RoundedCornerShape(14.dp), .fillMaxWidth()
colors = CardDefaults.cardColors( .padding(14.dp),
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),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp) horizontalArrangement = Arrangement.spacedBy(14.dp)
) { ) {
@ -247,7 +231,7 @@ internal fun PluginCard(
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
) )
} else { } else {
IconButton(onClick = onRemove) { GhostIconButton(onClick = onRemove) {
Icon( Icon(
FeatherIcons.Trash2, FeatherIcons.Trash2,
contentDescription = stringResource(Res.string.plugin_action_remove), contentDescription = stringResource(Res.string.plugin_action_remove),
@ -263,12 +247,11 @@ internal fun PluginCard(
else -> null else -> null
} }
authAction?.let { (action, label) -> authAction?.let { (action, label) ->
TextButton(onClick = action) { OutlineButton(onClick = action) {
Text( Text(
text = stringResource(label), text = stringResource(label),
style = MaterialTheme.typography.labelLarge style = MaterialTheme.typography.labelLarge
) )
}
} }
} }
} }
@ -284,4 +267,3 @@ private fun PluginAbility.displayLabel(): String {
PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble) PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble)
} }
} }

View File

@ -24,18 +24,13 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField 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.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -46,8 +41,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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.AdaptiveDropdownBottomSheet
import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem 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 spotube.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@ -107,8 +110,13 @@ internal fun <T> SelectionSettingCard(
) )
}, },
trigger = { onClick -> trigger = { onClick ->
TextButton(onClick = onClick) { OutlineButton(onClick = onClick) {
Text(optionLabel(selectedOption)) Text(optionLabel(selectedOption))
Icon(
imageVector = Iconsax.IconsaxArrowDown4,
contentDescription = null,
modifier = Modifier.size(14.dp),
)
} }
}, },
filter = filter, filter = filter,
@ -122,51 +130,47 @@ internal fun <T> SelectionSettingCard(
) )
if (!isWideLayout && isDialogOpen) { if (!isWideLayout && isDialogOpen) {
AlertDialog( ThemedDialog(
onDismissRequest = { isDialogOpen = false }, onDismissRequest = { isDialogOpen = false },
title = { title = {
Text(dialogTitle) Text(dialogTitle, style = MaterialTheme.typography.titleLarge)
}, },
text = { actions = {
Column( OutlineButton(onClick = { isDialogOpen = false }) {
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 }) {
Text(stringResource(Res.string.settings_action_close)) 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 normalizedValue = normalize(draft)
val errorMessage = validate(normalizedValue) val errorMessage = validate(normalizedValue)
AlertDialog( ThemedDialog(
onDismissRequest = { isDialogOpen = false }, onDismissRequest = { isDialogOpen = false },
title = { title = {
Text(dialogTitle) Text(dialogTitle, style = MaterialTheme.typography.titleLarge)
}, },
text = { actions = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { PrimaryButton(
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(
onClick = { onClick = {
if (errorMessage == null) { if (errorMessage == null) {
onValueSaved(normalizedValue) onValueSaved(normalizedValue)
@ -276,13 +254,36 @@ internal fun TextInputSettingCard(
) { ) {
Text(stringResource(Res.string.settings_action_save)) Text(stringResource(Res.string.settings_action_save))
} }
}, OutlineButton(onClick = { isDialogOpen = false }) {
dismissButton = {
TextButton(onClick = { isDialogOpen = false }) {
Text(stringResource(Res.string.settings_action_cancel)) 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,
)
} }
) }
} }
} }
} }

View File

@ -17,26 +17,11 @@
package dev.krtirtho.spotube.modules.settings.components 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import dev.krtirtho.spotube.core.ui.base.ListRowTile
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/** /**
* Reusable setting card item component for consistent styling * Reusable setting card item component for consistent styling
@ -48,54 +33,32 @@ fun SettingCardItem(
title: String, title: String,
subtitle: String? = null, subtitle: String? = null,
icon: (@Composable () -> Unit)? = null, icon: (@Composable () -> Unit)? = null,
trailingContent: (@Composable RowScope.() -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Card( ListRowTile(
modifier = modifier selected = false,
.fillMaxWidth() enabled = enabled,
.padding(horizontal = 8.dp, vertical = 4.dp) onClick = onClick,
.clip(RoundedCornerShape(12.dp)) modifier = modifier,
.clickable(onClick = onClick, enabled = enabled) leading = icon,
.then(if (!enabled) Modifier.alpha(0.5f) else Modifier), title = {
shape = RoundedCornerShape(12.dp), Text(
colors = CardDefaults.cardColors( title,
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) style = MaterialTheme.typography.labelLarge,
), color = MaterialTheme.colorScheme.onSurface
border = BorderStroke( )
width = 1.dp, },
color = MaterialTheme.colorScheme.outlineVariant subtitle = subtitle?.let {
), {
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
icon()
}
Column(modifier = Modifier.weight(1f)) {
Text( Text(
title, it,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface 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) trailing = trailingContent,
} )
}
} }

View File

@ -27,19 +27,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -53,6 +48,10 @@ import compose.icons.FeatherIcons
import compose.icons.feathericons.Droplet import compose.icons.feathericons.Droplet
import compose.icons.feathericons.Monitor import compose.icons.feathericons.Monitor
import spotube.composeapp.generated.resources.* 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.AccentColors
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.Theme
@ -67,41 +66,43 @@ internal fun LazyListScope.appearanceSection(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
) { ) {
settingsSectionHeader(Res.string.settings_section_appearance) settingsSectionHeader(Res.string.settings_section_appearance)
settingsSectionCard(
item { items = listOf(
SelectionSettingCard( {
title = stringResource(Res.string.settings_theme_title), SelectionSettingCard(
subtitle = stringResource( title = stringResource(Res.string.settings_theme_title),
Res.string.settings_theme_subtitle_current, subtitle = stringResource(
settings.theme.displayLabel() Res.string.settings_theme_subtitle_current,
), settings.theme.displayLabel()
icon = { ),
SettingsItemIcon(FeatherIcons.Monitor, stringResource(Res.string.settings_theme_title)) 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, AccentColorSettingCard(
optionLabel = { it.displayLabel() }, selectedAccent = settings.accentColor,
onOptionSelected = { theme -> icon = {
settingsViewModel.updateSettings { SettingsItemIcon(FeatherIcons.Droplet, stringResource(Res.string.settings_accent_title))
copy(theme = theme) },
} onColorSaved = { accent ->
} settingsViewModel.updateSettings {
) copy(accentColor = accent)
} }
}
item { )
AccentColorSettingCard(
selectedAccent = settings.accentColor,
icon = {
SettingsItemIcon(FeatherIcons.Droplet, stringResource(Res.string.settings_accent_title))
}, },
onColorSaved = { accent ->
settingsViewModel.updateSettings {
copy(accentColor = accent)
}
}
) )
} )
} }
@Composable @Composable
@ -155,75 +156,13 @@ private fun AccentColorSettingCard(
if (isDialogOpen) { if (isDialogOpen) {
var draftAccent by remember(selectedAccent, isDialogOpen) { mutableStateOf(selectedAccent) } var draftAccent by remember(selectedAccent, isDialogOpen) { mutableStateOf(selectedAccent) }
AlertDialog( ThemedDialog(
onDismissRequest = { isDialogOpen = false }, onDismissRequest = { isDialogOpen = false },
title = { title = {
Text(stringResource(Res.string.settings_accent_dialog_title)) Text(stringResource(Res.string.settings_accent_dialog_title), style = MaterialTheme.typography.titleLarge)
}, },
text = { actions = {
Column( PrimaryButton(
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(
onClick = { onClick = {
onColorSaved(draftAccent) onColorSaved(draftAccent)
isDialogOpen = false isDialogOpen = false
@ -231,13 +170,66 @@ private fun AccentColorSettingCard(
) { ) {
Text(stringResource(Res.string.settings_action_save)) Text(stringResource(Res.string.settings_action_save))
} }
}, OutlineButton(onClick = { isDialogOpen = false }) {
dismissButton = {
TextButton(onClick = { isDialogOpen = false }) {
Text(stringResource(Res.string.settings_action_cancel)) 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(),
)
}
}
}
} }
) }
} }
} }

View File

@ -19,14 +19,12 @@ package dev.krtirtho.spotube.modules.settings.sections
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.Folder import compose.icons.feathericons.Folder
import compose.icons.feathericons.HardDrive import compose.icons.feathericons.HardDrive
import spotube.composeapp.generated.resources.* 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.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
@ -41,83 +39,84 @@ internal fun LazyListScope.cacheSection(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
) { ) {
settingsSectionHeader(Res.string.settings_section_caching) settingsSectionHeader(Res.string.settings_section_caching)
settingsSectionCard(
item { items = listOf(
SwitchSettingCard( {
title = stringResource(Res.string.settings_enable_music_caching_title), SwitchSettingCard(
subtitle = stringResource(Res.string.settings_enable_music_caching_subtitle), title = stringResource(Res.string.settings_enable_music_caching_title),
icon = { subtitle = stringResource(Res.string.settings_enable_music_caching_subtitle),
SettingsItemIcon( icon = {
FeatherIcons.HardDrive, SettingsItemIcon(
stringResource(Res.string.settings_enable_music_caching_title) FeatherIcons.HardDrive,
) stringResource(Res.string.settings_enable_music_caching_title)
}, )
checked = settings.enableMusicCaching, },
onCheckedChange = { enabled -> checked = settings.enableMusicCaching,
settingsViewModel.updateSettings { onCheckedChange = { enabled ->
copy(enableMusicCaching = 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
} }
} )
},
{
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 @Composable
@ -147,7 +146,7 @@ private fun CacheFolderSettingCard(
) )
}, },
trailingContent = { trailingContent = {
IconButton( GhostIconButton(
onClick = { pickerLauncher.launch() }, onClick = { pickerLauncher.launch() },
enabled = enabled, enabled = enabled,
) { ) {

View File

@ -32,43 +32,45 @@ internal fun LazyListScope.desktopSection(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
) { ) {
settingsSectionHeader(Res.string.settings_section_desktop) settingsSectionHeader(Res.string.settings_section_desktop)
settingsSectionCard(
item { items = listOf(
SwitchSettingCard( {
title = stringResource(Res.string.settings_desktop_minimize_title), SwitchSettingCard(
subtitle = stringResource(Res.string.settings_desktop_minimize_subtitle), title = stringResource(Res.string.settings_desktop_minimize_title),
icon = { subtitle = stringResource(Res.string.settings_desktop_minimize_subtitle),
SettingsItemIcon( icon = {
FeatherIcons.Minimize2, SettingsItemIcon(
stringResource(Res.string.settings_desktop_minimize_title) FeatherIcons.Minimize2,
stringResource(Res.string.settings_desktop_minimize_title)
)
},
checked = settings.minimizeToTray,
onCheckedChange = { enabled ->
settingsViewModel.updateSettings {
copy(minimizeToTray = enabled)
}
}
) )
}, },
checked = settings.minimizeToTray, {
onCheckedChange = { enabled -> SwitchSettingCard(
settingsViewModel.updateSettings { title = stringResource(Res.string.settings_desktop_discord_title),
copy(minimizeToTray = enabled) subtitle = stringResource(Res.string.settings_desktop_discord_subtitle),
} icon = {
} SettingsItemIcon(
) FeatherIcons.Activity,
} stringResource(Res.string.settings_desktop_discord_title)
)
item { },
SwitchSettingCard( checked = settings.discordRichPresence,
title = stringResource(Res.string.settings_desktop_discord_title), onCheckedChange = { enabled ->
subtitle = stringResource(Res.string.settings_desktop_discord_subtitle), settingsViewModel.updateSettings {
icon = { copy(discordRichPresence = enabled)
SettingsItemIcon( }
FeatherIcons.Activity, }
stringResource(Res.string.settings_desktop_discord_title)
) )
}, },
checked = settings.discordRichPresence,
onCheckedChange = { enabled ->
settingsViewModel.updateSettings {
copy(discordRichPresence = enabled)
}
}
) )
} )
} }

View File

@ -26,12 +26,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
@ -49,6 +46,10 @@ import compose.icons.feathericons.PlusSquare
import compose.icons.feathericons.Sliders import compose.icons.feathericons.Sliders
import compose.icons.feathericons.Trash2 import compose.icons.feathericons.Trash2
import spotube.composeapp.generated.resources.* 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.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
@ -68,73 +69,73 @@ internal fun LazyListScope.downloadsSection(
) )
settingsSectionHeader(Res.string.settings_section_downloads) settingsSectionHeader(Res.string.settings_section_downloads)
settingsSectionCard(
item { items = listOf(
DownloadFolderSettingCard( {
folder = settings.overloadedDownloadFolder, DownloadFolderSettingCard(
onFolderSelected = { folder -> folder = settings.overloadedDownloadFolder,
settingsViewModel.updateSettings { onFolderSelected = { folder ->
copy(overloadedDownloadFolder = 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))
}, },
selectedOption = settings.downloadMusicFormat, {
options = downloadFormats, LocalMediaFoldersSettingCard(
optionLabel = { it.displayLabel() }, folders = settings.localMediaFolders,
onOptionSelected = { format -> onFoldersSaved = { folders ->
settingsViewModel.updateSettings { settingsViewModel.updateSettings {
copy( copy(localMediaFolders = folders)
downloadMusicFormat = format, }
downloadMusicQuality = format.resolveQuality(downloadMusicQuality), }
) )
} },
} {
) SelectionSettingCard(
} title = stringResource(Res.string.settings_download_format_title),
subtitle = stringResource(
item { Res.string.settings_download_format_subtitle_current,
SelectionSettingCard( settings.downloadMusicFormat.displayLabel()
title = stringResource(Res.string.settings_download_quality_title), ),
subtitle = stringResource( icon = {
Res.string.settings_subtitle_current, SettingsItemIcon(FeatherIcons.Disc, stringResource(Res.string.settings_download_format_title))
settings.downloadMusicQuality.displayLabel() },
), selectedOption = settings.downloadMusicFormat,
icon = { options = downloadFormats,
SettingsItemIcon(FeatherIcons.Sliders, stringResource(Res.string.settings_download_quality_title)) 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 @Composable
@ -160,7 +161,7 @@ private fun DownloadFolderSettingCard(
SettingsItemIcon(FeatherIcons.Folder, stringResource(Res.string.settings_download_folder_title)) SettingsItemIcon(FeatherIcons.Folder, stringResource(Res.string.settings_download_folder_title))
}, },
trailingContent = { trailingContent = {
IconButton(onClick = { pickerLauncher.launch() }) { GhostIconButton(onClick = { pickerLauncher.launch() }) {
Icon( Icon(
FeatherIcons.Folder, FeatherIcons.Folder,
contentDescription = stringResource(Res.string.settings_download_folder_title) contentDescription = stringResource(Res.string.settings_download_folder_title)
@ -199,7 +200,7 @@ private fun LocalMediaFoldersSettingCard(
) )
}, },
trailingContent = { trailingContent = {
TextButton(onClick = { isDialogOpen = true }) { OutlineButton(onClick = { isDialogOpen = true }) {
Text(stringResource(Res.string.settings_local_media_folders_manage)) Text(stringResource(Res.string.settings_local_media_folders_manage))
} }
}, },
@ -235,76 +236,16 @@ private fun LocalMediaFoldersDialog(
} }
} }
AlertDialog( ThemedDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { 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 = { actions = {
Column( PrimaryButton(
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(
onClick = { onClick = {
onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct()) onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct())
onDismiss() onDismiss()
@ -312,12 +253,69 @@ private fun LocalMediaFoldersDialog(
) { ) {
Text(stringResource(Res.string.settings_action_save)) Text(stringResource(Res.string.settings_action_save))
} }
}, OutlineButton(onClick = onDismiss) {
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.settings_action_cancel)) 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,
),
)
}
}
}
}
}
} }
) }
} }

View File

@ -34,59 +34,61 @@ internal fun LazyListScope.languageRegionSection(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
) { ) {
settingsSectionHeader(Res.string.settings_section_language_region) settingsSectionHeader(Res.string.settings_section_language_region)
settingsSectionCard(
item { items = listOf(
SelectionSettingCard( {
title = stringResource(Res.string.settings_language_title), SelectionSettingCard(
subtitle = stringResource( title = stringResource(Res.string.settings_language_title),
Res.string.settings_language_subtitle_current, subtitle = stringResource(
settings.language.displayName Res.string.settings_language_subtitle_current,
), settings.language.displayName
icon = { ),
SettingsItemIcon( icon = {
FeatherIcons.Globe, SettingsItemIcon(
stringResource(Res.string.settings_language_title) 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, SelectionSettingCard(
optionLabel = { title = stringResource(Res.string.settings_country_title),
stringResource(Res.string.settings_option_name_and_code, it.displayName, it.locale) subtitle = stringResource(
}, Res.string.settings_country_subtitle_current,
onOptionSelected = { value -> settings.country.displayName
settingsViewModel.updateSettings { ),
copy(language = value) icon = {
} SettingsItemIcon(
}, FeatherIcons.MapPin,
filter = { item, query -> item.label.contains(query, ignoreCase = true) }, stringResource(Res.string.settings_country_title)
) )
} },
selectedOption = settings.country,
item { options = CountryCode.entries,
SelectionSettingCard( optionLabel = {
title = stringResource(Res.string.settings_country_title), stringResource(Res.string.settings_option_name_and_code, it.displayName, it.code)
subtitle = stringResource( },
Res.string.settings_country_subtitle_current, onOptionSelected = { value ->
settings.country.displayName settingsViewModel.updateSettings {
), copy(country = value)
icon = { }
SettingsItemIcon( },
FeatherIcons.MapPin, filter = { item, query -> item.label.contains(query, ignoreCase = true) },
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) },
) )
} )
} }

View File

@ -43,118 +43,117 @@ internal fun LazyListScope.playbackSection(
) )
settingsSectionHeader(Res.string.settings_section_playback) settingsSectionHeader(Res.string.settings_section_playback)
settingsSectionCard(
item { items = listOf(
SelectionSettingCard( {
title = stringResource(Res.string.settings_streaming_format_title), SelectionSettingCard(
subtitle = stringResource( title = stringResource(Res.string.settings_streaming_format_title),
Res.string.settings_streaming_format_subtitle_current, subtitle = stringResource(
settings.streamingMusicFormat.displayLabel() Res.string.settings_streaming_format_subtitle_current,
), settings.streamingMusicFormat.displayLabel()
icon = { ),
SettingsItemIcon(FeatherIcons.Radio, stringResource(Res.string.settings_streaming_format_title)) icon = {
}, SettingsItemIcon(FeatherIcons.Radio, stringResource(Res.string.settings_streaming_format_title))
selectedOption = settings.streamingMusicFormat, },
options = streamingFormats, selectedOption = settings.streamingMusicFormat,
optionLabel = { it.displayLabel() }, options = streamingFormats,
onOptionSelected = { format -> optionLabel = { it.displayLabel() },
settingsViewModel.updateSettings { onOptionSelected = { format ->
copy( settingsViewModel.updateSettings {
streamingMusicFormat = format, copy(
streamingMusicQuality = format.resolveQuality(streamingMusicQuality), 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)
) )
}, },
checked = settings.enableEndlessPlayback, {
onCheckedChange = { enabled -> SelectionSettingCard(
settingsViewModel.updateSettings { title = stringResource(Res.string.settings_streaming_quality_title),
copy(enableEndlessPlayback = enabled) subtitle = stringResource(
} Res.string.settings_subtitle_current,
} settings.streamingMusicQuality.displayLabel()
) ),
} icon = {
SettingsItemIcon(FeatherIcons.Sliders, stringResource(Res.string.settings_streaming_quality_title))
item { },
SwitchSettingCard( selectedOption = settings.streamingMusicQuality,
title = stringResource(Res.string.settings_enable_connect_title), options = streamingQualities,
subtitle = stringResource(Res.string.settings_enable_connect_subtitle), optionLabel = { it.displayLabel() },
icon = { onOptionSelected = { quality ->
SettingsItemIcon(FeatherIcons.Cast, stringResource(Res.string.settings_enable_connect_title)) settingsViewModel.updateSettings {
copy(streamingMusicQuality = quality)
}
}
)
}, },
checked = settings.enableConnect, {
onCheckedChange = { enabled -> SwitchSettingCard(
settingsViewModel.updateSettings { title = stringResource(Res.string.settings_enable_endless_playback_title),
copy(enableConnect = enabled) subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle),
} icon = {
} SettingsItemIcon(
) FeatherIcons.Repeat,
} stringResource(Res.string.settings_enable_endless_playback_title)
)
item { },
val error_whole_number = stringResource(Res.string.settings_error_whole_number) checked = settings.enableEndlessPlayback,
val error_port_range = stringResource(Res.string.settings_error_port_range) onCheckedChange = { enabled ->
settingsViewModel.updateSettings {
TextInputSettingCard( copy(enableEndlessPlayback = enabled)
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), SwitchSettingCard(
placeholder = stringResource(Res.string.settings_playback_port_placeholder), title = stringResource(Res.string.settings_enable_connect_title),
normalize = { it.trim() }, subtitle = stringResource(Res.string.settings_enable_connect_subtitle),
validate = { value -> icon = {
val port = value.toIntOrNull() SettingsItemIcon(FeatherIcons.Cast, stringResource(Res.string.settings_enable_connect_title))
when { },
port == null -> error_whole_number checked = settings.enableConnect,
port !in 1..65535 -> error_port_range onCheckedChange = { enabled ->
else -> null 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())
}
}
) )
} )
} }

View File

@ -17,19 +17,12 @@
package dev.krtirtho.spotube.modules.settings.sections 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.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.ChevronRight
import compose.icons.feathericons.Package import compose.icons.feathericons.Package
@ -42,43 +35,30 @@ import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.pluginsSection( internal fun LazyListScope.pluginsSection(
navigatorCommands: NavigationCommands navigatorCommands: NavigationCommands
) { ) {
item { settingsSectionHeader(Res.string.settings_section_plugins)
Text( settingsSectionCard(
stringResource(Res.string.settings_section_plugins), items = listOf {
style = MaterialTheme.typography.labelMedium, SettingCardItem(
color = Color.Gray, title = stringResource(Res.string.settings_plugins_manage_title),
modifier = Modifier subtitle = stringResource(Res.string.settings_plugins_manage_subtitle),
.fillMaxSize() icon = {
.padding(horizontal = 16.dp, vertical = 8.dp) SettingsItemIcon(
) FeatherIcons.Package,
} stringResource(Res.string.settings_section_plugins),
item { Color(0xFFFF9800)
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)
) )
},
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)
}
)
}
} }

View File

@ -18,10 +18,12 @@
package dev.krtirtho.spotube.modules.settings.sections package dev.krtirtho.spotube.modules.settings.sections
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface 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 dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import dev.krtirtho.spotube.core.ui.base.Card
private val standardLossyQualities = listOf( private val standardLossyQualities = listOf(
AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 44_000),
@ -172,3 +175,30 @@ internal fun normalizePath(path: String): String {
return path.trim().trimEnd('/', '\\') 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()
}
}
}
}
}

View File

@ -33,24 +33,25 @@ internal fun LazyListScope.updatesSection(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
) { ) {
settingsSectionHeader(Res.string.settings_section_updates) settingsSectionHeader(Res.string.settings_section_updates)
settingsSectionCard(
item { items = listOf {
SwitchSettingCard( SwitchSettingCard(
title = stringResource(Res.string.settings_updates_auto_check_title), title = stringResource(Res.string.settings_updates_auto_check_title),
subtitle = stringResource(Res.string.settings_updates_auto_check_subtitle), subtitle = stringResource(Res.string.settings_updates_auto_check_subtitle),
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
FeatherIcons.RefreshCw, FeatherIcons.RefreshCw,
stringResource(Res.string.settings_updates_auto_check_title) stringResource(Res.string.settings_updates_auto_check_title)
) )
}, },
checked = settings.autoCheckForUpdates, checked = settings.autoCheckForUpdates,
onCheckedChange = { enabled -> onCheckedChange = { enabled ->
settingsViewModel.updateSettings { settingsViewModel.updateSettings {
copy(autoCheckForUpdates = enabled) copy(autoCheckForUpdates = enabled)
}
} }
} )
) }
} )
} }