mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
Compare commits
No commits in common. "9d79d055ba194c6d48415d0968956f261a1f36d3" and "6ac934ad19a329fec005d7b7a3df76fd6a5a094f" have entirely different histories.
9d79d055ba
...
6ac934ad19
@ -85,7 +85,7 @@ val navigationModule = module {
|
|||||||
LibraryScreen()
|
LibraryScreen()
|
||||||
}
|
}
|
||||||
navigation<Routes.Settings> {
|
navigation<Routes.Settings> {
|
||||||
SettingsScreen(settingsViewModel = koinViewModel())
|
SettingsScreen(pluginManager = get(), settingsViewModel = koinViewModel())
|
||||||
}
|
}
|
||||||
navigation<Routes.Plugins> {
|
navigation<Routes.Plugins> {
|
||||||
PluginScreen(pluginManager = get())
|
PluginScreen(pluginManager = get())
|
||||||
|
|||||||
@ -299,26 +299,6 @@ fun IconButton(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GhostButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
content: @Composable RowScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current
|
|
||||||
val style = theme ?: baseTheme.buttons.ghost
|
|
||||||
OutlineButton(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = modifier,
|
|
||||||
enabled = enabled,
|
|
||||||
hoverOnly = true,
|
|
||||||
theme = style,
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GhostIconButton(
|
fun GhostIconButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
@ -328,13 +308,43 @@ fun GhostIconButton(
|
|||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val baseTheme = LocalBaseUITheme.current
|
val baseTheme = LocalBaseUITheme.current
|
||||||
val style = theme ?: baseTheme.iconButtons.ghost
|
val outlineStyle = baseTheme.iconButtons.outline
|
||||||
|
val ghostStyle = theme ?: BaseUITheme.ButtonStyle(
|
||||||
|
colors = BaseUITheme.InteractionState(
|
||||||
|
normal = BaseUITheme.ButtonColors(
|
||||||
|
background = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)),
|
||||||
|
foreground = outlineStyle.colors.normal.foreground,
|
||||||
|
highlight = outlineStyle.colors.normal.highlight,
|
||||||
|
),
|
||||||
|
hovered = outlineStyle.colors.hovered,
|
||||||
|
pressed = outlineStyle.colors.pressed,
|
||||||
|
focused = BaseUITheme.ButtonColors(
|
||||||
|
background = Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)),
|
||||||
|
foreground = outlineStyle.colors.normal.foreground,
|
||||||
|
highlight = outlineStyle.colors.normal.highlight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
shape = outlineStyle.shape,
|
||||||
|
shadow = BaseUITheme.InteractionState(
|
||||||
|
normal = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
|
||||||
|
hovered = outlineStyle.shadow.hovered,
|
||||||
|
pressed = outlineStyle.shadow.pressed,
|
||||||
|
focused = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent),
|
||||||
|
),
|
||||||
|
border = BaseUITheme.InteractionState(
|
||||||
|
normal = BaseUITheme.Border(Color.Transparent, 0.dp),
|
||||||
|
hovered = outlineStyle.border.hovered,
|
||||||
|
pressed = outlineStyle.border.pressed,
|
||||||
|
focused = BaseUITheme.Border(Color.Transparent, 0.dp),
|
||||||
|
),
|
||||||
|
padding = outlineStyle.padding,
|
||||||
|
)
|
||||||
OutlineButton(
|
OutlineButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
hoverOnly = true,
|
hoverOnly = true,
|
||||||
theme = style,
|
theme = ghostStyle,
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,186 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,172 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -48,11 +48,8 @@ data class BaseUITheme(
|
|||||||
val slider: SliderTheme,
|
val slider: SliderTheme,
|
||||||
val checkBox: CheckBoxTheme,
|
val checkBox: CheckBoxTheme,
|
||||||
val chipTab: ChipTabTheme,
|
val chipTab: ChipTabTheme,
|
||||||
val toggle: ToggleTheme,
|
|
||||||
val card: CardTheme,
|
val card: CardTheme,
|
||||||
val listRowTile: ListRowTheme,
|
val listRowTile: ListRowTheme,
|
||||||
val dialog: DialogTheme,
|
|
||||||
val dropdownMenu: DropdownMenuTheme,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
@ -161,27 +158,6 @@ 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>,
|
||||||
@ -201,13 +177,6 @@ data class BaseUITheme(
|
|||||||
val selected: ButtonStyle,
|
val selected: ButtonStyle,
|
||||||
val unselected: ButtonStyle,
|
val unselected: ButtonStyle,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ToggleTheme(
|
|
||||||
val checked: ButtonStyle,
|
|
||||||
val unchecked: ButtonStyle,
|
|
||||||
val thumb: InteractionState<Color>,
|
|
||||||
val thumbShadow: InteractionState<Shadow>,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalBaseUITheme = staticCompositionLocalOf<BaseUITheme> {
|
val LocalBaseUITheme = staticCompositionLocalOf<BaseUITheme> {
|
||||||
@ -550,16 +519,6 @@ fun rememberBaseUITheme(): BaseUITheme {
|
|||||||
PaddingValues(horizontal = 14.dp, vertical = 4.dp),
|
PaddingValues(horizontal = 14.dp, vertical = 4.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
val toggleShape = RoundedCornerShape(50)
|
|
||||||
val toggleShapeState =
|
|
||||||
BaseUITheme.InteractionState<Shape>(toggleShape, toggleShape, toggleShape, toggleShape)
|
|
||||||
val togglePadding = BaseUITheme.InteractionState(
|
|
||||||
PaddingValues(2.dp),
|
|
||||||
PaddingValues(2.dp),
|
|
||||||
PaddingValues(2.dp),
|
|
||||||
PaddingValues(2.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
return BaseUITheme(
|
return BaseUITheme(
|
||||||
buttons = BaseUITheme.ButtonVariants(
|
buttons = BaseUITheme.ButtonVariants(
|
||||||
primary = primaryStyle,
|
primary = primaryStyle,
|
||||||
@ -772,161 +731,6 @@ fun rememberBaseUITheme(): BaseUITheme {
|
|||||||
padding = chipTabPadding,
|
padding = chipTabPadding,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
toggle = BaseUITheme.ToggleTheme(
|
|
||||||
checked = BaseUITheme.ButtonStyle(
|
|
||||||
colors = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.ButtonColors(
|
|
||||||
background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)),
|
|
||||||
foreground = scheme.onPrimary,
|
|
||||||
highlight = Color.White.copy(alpha = 0.25f),
|
|
||||||
),
|
|
||||||
hovered = BaseUITheme.ButtonColors(
|
|
||||||
background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)),
|
|
||||||
foreground = scheme.onPrimary,
|
|
||||||
highlight = Color.White.copy(alpha = 0.25f),
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.ButtonColors(
|
|
||||||
background = Brush.verticalGradient(
|
|
||||||
listOf(
|
|
||||||
scheme.primary.copy(alpha = 0.85f),
|
|
||||||
scheme.primary
|
|
||||||
)
|
|
||||||
),
|
|
||||||
foreground = scheme.onPrimary,
|
|
||||||
highlight = Color.White.copy(alpha = 0.25f),
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.ButtonColors(
|
|
||||||
background = Brush.verticalGradient(listOf(scheme.primary, scheme.primary)),
|
|
||||||
foreground = scheme.onPrimary,
|
|
||||||
highlight = Color.White.copy(alpha = 0.25f),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
shape = toggleShapeState,
|
|
||||||
shadow = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Shadow(
|
|
||||||
4.dp,
|
|
||||||
true,
|
|
||||||
scheme.primary.copy(alpha = 0.3f),
|
|
||||||
scheme.primary.copy(alpha = 0.35f)
|
|
||||||
),
|
|
||||||
hovered = BaseUITheme.Shadow(
|
|
||||||
6.dp,
|
|
||||||
true,
|
|
||||||
scheme.primary.copy(alpha = 0.4f),
|
|
||||||
scheme.primary.copy(alpha = 0.45f)
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.Shadow(
|
|
||||||
1.dp,
|
|
||||||
true,
|
|
||||||
scheme.primary.copy(alpha = 0.2f),
|
|
||||||
scheme.primary.copy(alpha = 0.25f)
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.Shadow(
|
|
||||||
4.dp,
|
|
||||||
true,
|
|
||||||
scheme.primary.copy(alpha = 0.3f),
|
|
||||||
scheme.primary.copy(alpha = 0.35f)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
border = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Border(scheme.primary, 0.5.dp),
|
|
||||||
hovered = BaseUITheme.Border(scheme.primary, 0.5.dp),
|
|
||||||
pressed = BaseUITheme.Border(scheme.primary, 0.5.dp),
|
|
||||||
focused = BaseUITheme.Border(scheme.primary, 0.5.dp),
|
|
||||||
),
|
|
||||||
padding = togglePadding,
|
|
||||||
),
|
|
||||||
unchecked = BaseUITheme.ButtonStyle(
|
|
||||||
colors = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.ButtonColors(
|
|
||||||
background = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
|
|
||||||
foreground = scheme.onSurface,
|
|
||||||
highlight = highlight,
|
|
||||||
),
|
|
||||||
hovered = BaseUITheme.ButtonColors(
|
|
||||||
background = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
|
|
||||||
foreground = scheme.onSurface,
|
|
||||||
highlight = highlight,
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.ButtonColors(
|
|
||||||
background = Brush.verticalGradient(listOf(containerPressed, containerPressed)),
|
|
||||||
foreground = scheme.onSurface,
|
|
||||||
highlight = highlight,
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.ButtonColors(
|
|
||||||
background = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
|
|
||||||
foreground = scheme.onSurface,
|
|
||||||
highlight = highlight,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
shape = toggleShapeState,
|
|
||||||
shadow = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Shadow(
|
|
||||||
4.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.15f),
|
|
||||||
shadowColor.copy(alpha = 0.18f)
|
|
||||||
),
|
|
||||||
hovered = BaseUITheme.Shadow(
|
|
||||||
6.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.22f),
|
|
||||||
shadowColor.copy(alpha = 0.26f)
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.Shadow(
|
|
||||||
1.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.08f),
|
|
||||||
shadowColor.copy(alpha = 0.1f)
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.Shadow(
|
|
||||||
4.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.15f),
|
|
||||||
shadowColor.copy(alpha = 0.18f)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
border = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Border(border, 0.5.dp),
|
|
||||||
hovered = BaseUITheme.Border(border, 0.5.dp),
|
|
||||||
pressed = BaseUITheme.Border(border.copy(alpha = 0.7f), 0.5.dp),
|
|
||||||
focused = BaseUITheme.Border(border, 0.5.dp),
|
|
||||||
),
|
|
||||||
padding = togglePadding,
|
|
||||||
),
|
|
||||||
thumb = BaseUITheme.InteractionState(
|
|
||||||
normal = Color.White,
|
|
||||||
hovered = Color.White,
|
|
||||||
pressed = Color.White,
|
|
||||||
focused = Color.White,
|
|
||||||
),
|
|
||||||
thumbShadow = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Shadow(
|
|
||||||
3.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.2f),
|
|
||||||
shadowColor.copy(alpha = 0.25f)
|
|
||||||
),
|
|
||||||
hovered = BaseUITheme.Shadow(
|
|
||||||
4.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.28f),
|
|
||||||
shadowColor.copy(alpha = 0.32f)
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.Shadow(
|
|
||||||
1.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.12f),
|
|
||||||
shadowColor.copy(alpha = 0.15f)
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.Shadow(
|
|
||||||
3.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.2f),
|
|
||||||
shadowColor.copy(alpha = 0.25f)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
card = BaseUITheme.CardTheme(
|
card = BaseUITheme.CardTheme(
|
||||||
background = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
|
background = Brush.verticalGradient(listOf(containerLighter, containerDarker)),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
@ -1000,47 +804,6 @@ 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),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,264 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.animateDpAsState
|
|
||||||
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.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
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.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.max
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
private val ToggleWidth = 44.dp
|
|
||||||
private val ToggleHeight = 24.dp
|
|
||||||
private val ThumbSize = 20.dp
|
|
||||||
|
|
||||||
private data class ResolvedToggleState(
|
|
||||||
val colors: BaseUITheme.ButtonColors,
|
|
||||||
val shape: Shape,
|
|
||||||
val shadow: BaseUITheme.Shadow,
|
|
||||||
val border: BaseUITheme.Border,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun resolveToggleState(
|
|
||||||
style: BaseUITheme.ButtonStyle,
|
|
||||||
isPressed: Boolean,
|
|
||||||
isHovered: Boolean,
|
|
||||||
): ResolvedToggleState {
|
|
||||||
return when {
|
|
||||||
isPressed -> ResolvedToggleState(
|
|
||||||
style.colors.pressed,
|
|
||||||
style.shape.pressed,
|
|
||||||
style.shadow.pressed,
|
|
||||||
style.border.pressed,
|
|
||||||
)
|
|
||||||
|
|
||||||
isHovered -> ResolvedToggleState(
|
|
||||||
style.colors.hovered,
|
|
||||||
style.shape.hovered,
|
|
||||||
style.shadow.hovered,
|
|
||||||
style.border.hovered,
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> ResolvedToggleState(
|
|
||||||
style.colors.normal,
|
|
||||||
style.shape.normal,
|
|
||||||
style.shadow.normal,
|
|
||||||
style.border.normal,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Toggle(
|
|
||||||
checked: Boolean,
|
|
||||||
onCheckedChange: ((Boolean) -> Unit)?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
interactionSource: MutableInteractionSource? = null,
|
|
||||||
theme: BaseUITheme.ToggleTheme? = null,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current.toggle
|
|
||||||
val toggleTheme = theme ?: baseTheme
|
|
||||||
val source = interactionSource ?: remember { MutableInteractionSource() }
|
|
||||||
val isPressed by source.collectIsPressedAsState()
|
|
||||||
val isHovered by source.collectIsHoveredAsState()
|
|
||||||
|
|
||||||
val style = if (checked) toggleTheme.checked else toggleTheme.unchecked
|
|
||||||
val resolved = resolveToggleState(style, isPressed, isHovered)
|
|
||||||
|
|
||||||
val thumbColor = when {
|
|
||||||
isPressed -> toggleTheme.thumb.pressed
|
|
||||||
isHovered -> toggleTheme.thumb.hovered
|
|
||||||
else -> toggleTheme.thumb.normal
|
|
||||||
}
|
|
||||||
|
|
||||||
val thumbShadowState = when {
|
|
||||||
isPressed -> toggleTheme.thumbShadow.pressed
|
|
||||||
isHovered -> toggleTheme.thumbShadow.hovered
|
|
||||||
else -> toggleTheme.thumbShadow.normal
|
|
||||||
}
|
|
||||||
|
|
||||||
val borderColor = when {
|
|
||||||
!enabled -> resolved.colors.foreground.copy(alpha = 0.38f)
|
|
||||||
else -> resolved.border.color
|
|
||||||
}
|
|
||||||
|
|
||||||
val thumbProgress by animateFloatAsState(
|
|
||||||
targetValue = if (checked) 1f else 0f,
|
|
||||||
animationSpec = tween(durationMillis = 200),
|
|
||||||
label = "thumbProgress",
|
|
||||||
)
|
|
||||||
|
|
||||||
val thumbScale by animateFloatAsState(
|
|
||||||
targetValue = when {
|
|
||||||
isPressed -> 0.92f
|
|
||||||
isHovered -> 1.05f
|
|
||||||
else -> 1f
|
|
||||||
},
|
|
||||||
animationSpec = tween(durationMillis = 150),
|
|
||||||
label = "thumbScale",
|
|
||||||
)
|
|
||||||
|
|
||||||
val lift = if (isHovered && !isPressed && onCheckedChange != null) (-1).dp else 0.dp
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val thumbOffsetXPx by animateDpAsState(
|
|
||||||
targetValue = with(density) {
|
|
||||||
val trackWidth = ToggleWidth - ThumbSize
|
|
||||||
max((thumbProgress * trackWidth.toPx()).toDp() - 4.dp, 2.dp)
|
|
||||||
},
|
|
||||||
animationSpec = tween(durationMillis = 200),
|
|
||||||
label = "thumbOffsetX",
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.width(ToggleWidth)
|
|
||||||
.height(ToggleHeight)
|
|
||||||
.graphicsLayer {
|
|
||||||
translationY = lift.toPx()
|
|
||||||
}
|
|
||||||
.then(
|
|
||||||
if (resolved.shadow.elevation > 0.dp) {
|
|
||||||
Modifier.shadow(
|
|
||||||
elevation = resolved.shadow.elevation,
|
|
||||||
shape = resolved.shape,
|
|
||||||
ambientColor = resolved.shadow.ambientColor,
|
|
||||||
spotColor = resolved.shadow.spotColor,
|
|
||||||
)
|
|
||||||
} else Modifier
|
|
||||||
)
|
|
||||||
.clip(resolved.shape)
|
|
||||||
.background(resolved.colors.background, resolved.shape)
|
|
||||||
.border(BorderStroke(resolved.border.width, borderColor), resolved.shape)
|
|
||||||
.highlight(resolved.colors.highlight)
|
|
||||||
.then(
|
|
||||||
if (onCheckedChange != null) {
|
|
||||||
Modifier
|
|
||||||
.hoverable(interactionSource = source, enabled = enabled)
|
|
||||||
.clickable(
|
|
||||||
enabled = enabled,
|
|
||||||
interactionSource = source,
|
|
||||||
indication = null,
|
|
||||||
onClick = { onCheckedChange(!checked) },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.offset(x = thumbOffsetXPx)
|
|
||||||
.size(ThumbSize)
|
|
||||||
.graphicsLayer {
|
|
||||||
scaleX = thumbScale
|
|
||||||
scaleY = thumbScale
|
|
||||||
}
|
|
||||||
.shadow(
|
|
||||||
elevation = thumbShadowState.elevation,
|
|
||||||
shape = CircleShape,
|
|
||||||
ambientColor = thumbShadowState.ambientColor,
|
|
||||||
spotColor = thumbShadowState.spotColor,
|
|
||||||
)
|
|
||||||
.background(
|
|
||||||
brush = Brush.radialGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.White.copy(alpha = 0.6f),
|
|
||||||
thumbColor.copy(alpha = 0.95f),
|
|
||||||
thumbColor,
|
|
||||||
),
|
|
||||||
center = Offset(0f, -0.55f),
|
|
||||||
radius = 1.1f,
|
|
||||||
),
|
|
||||||
shape = CircleShape,
|
|
||||||
)
|
|
||||||
.border(
|
|
||||||
width = 0.8.dp,
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.White.copy(alpha = 0.55f),
|
|
||||||
Color.White.copy(alpha = 0.08f),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
shape = CircleShape,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
private fun TogglePreview() {
|
|
||||||
MaterialTheme {
|
|
||||||
val theme = rememberBaseUITheme()
|
|
||||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.background,
|
|
||||||
modifier = Modifier.padding(24.dp),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Toggle(checked = true, onCheckedChange = {})
|
|
||||||
Toggle(checked = false, onCheckedChange = {})
|
|
||||||
Toggle(checked = true, onCheckedChange = null)
|
|
||||||
Toggle(checked = false, onCheckedChange = null, enabled = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,7 +17,6 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -34,10 +33,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
|
||||||
@ -65,9 +64,6 @@ 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
|
||||||
@ -109,9 +105,10 @@ fun AdaptiveDropdownBottomSheet(
|
|||||||
trigger { expanded = true }
|
trigger { expanded = true }
|
||||||
|
|
||||||
if (isLargeScreen) {
|
if (isLargeScreen) {
|
||||||
DropdownMenu(
|
ShadcnDropdownMenu(
|
||||||
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()
|
||||||
@ -140,24 +137,28 @@ fun AdaptiveDropdownBottomSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
items.forEach { item ->
|
val hasSelection = items.any { it.selected }
|
||||||
if (query.isNotBlank() && filter != null && !filter(item, query)) {
|
|
||||||
return@forEach
|
items
|
||||||
|
.forEach { item ->
|
||||||
|
if (query.isNotBlank() && filter != null && !filter(item, query)) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (item.dividerBefore) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ShadcnDropdownMenuItem(
|
||||||
|
item = item,
|
||||||
|
onClick = {
|
||||||
|
item.onClick()
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
hasSelection = hasSelection,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
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) {
|
||||||
@ -176,6 +177,117 @@ 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(
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@ -51,7 +52,6 @@ import coil3.compose.AsyncImage
|
|||||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
import dev.krtirtho.spotube.core.ui.base.TextField
|
||||||
import dev.krtirtho.spotube.core.ui.base.Toggle
|
|
||||||
import io.github.vinceglb.filekit.dialogs.FileKitType
|
import io.github.vinceglb.filekit.dialogs.FileKitType
|
||||||
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
|
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
|
||||||
import io.github.vinceglb.filekit.readBytes
|
import io.github.vinceglb.filekit.readBytes
|
||||||
@ -265,7 +265,7 @@ private fun PlaylistFormFields(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Toggle(checked = isPublic, onCheckedChange = onIsPublicChange)
|
Switch(checked = isPublic, onCheckedChange = onIsPublicChange)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@ -285,7 +285,7 @@ private fun PlaylistFormFields(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Toggle(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange)
|
Switch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@ -21,17 +21,16 @@ 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
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
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.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.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
|
||||||
@ -47,25 +46,13 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import compose.icons.FeatherIcons
|
import compose.icons.FeatherIcons
|
||||||
import compose.icons.feathericons.Activity
|
|
||||||
import compose.icons.feathericons.AlignLeft
|
|
||||||
import compose.icons.feathericons.Check
|
|
||||||
import compose.icons.feathericons.ExternalLink
|
|
||||||
import compose.icons.feathericons.FileText
|
|
||||||
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.AdaptiveMenuItem
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
|
|
||||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||||
import dev.krtirtho.spotube.getPlatform
|
import dev.krtirtho.spotube.getPlatform
|
||||||
import dev.krtirtho.spotube.modules.plugin.components.InstallSection
|
import dev.krtirtho.spotube.modules.plugin.components.InstallSection
|
||||||
@ -78,7 +65,6 @@ import io.github.vinceglb.filekit.readBytes
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import spotube.composeapp.generated.resources.Res
|
import spotube.composeapp.generated.resources.Res
|
||||||
import spotube.composeapp.generated.resources.plugin_empty_subtitle
|
import spotube.composeapp.generated.resources.plugin_empty_subtitle
|
||||||
import spotube.composeapp.generated.resources.plugin_empty_title
|
import spotube.composeapp.generated.resources.plugin_empty_title
|
||||||
@ -89,18 +75,6 @@ import spotube.composeapp.generated.resources.plugin_installed_count
|
|||||||
import spotube.composeapp.generated.resources.plugin_installed_plural
|
import spotube.composeapp.generated.resources.plugin_installed_plural
|
||||||
import spotube.composeapp.generated.resources.plugin_installed_singular
|
import spotube.composeapp.generated.resources.plugin_installed_singular
|
||||||
import spotube.composeapp.generated.resources.plugin_screen_title
|
import spotube.composeapp.generated.resources.plugin_screen_title
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_action_change
|
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_action_select
|
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_ability_audio
|
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics
|
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_ability_metadata
|
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble
|
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_clear
|
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_default_ability_title
|
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_manage_title
|
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_no_plugins
|
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_no_selection
|
|
||||||
import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -215,44 +189,6 @@ fun PluginScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Default ability plugin selectors ─────────────────
|
|
||||||
item {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 4.dp, bottom = 4.dp)
|
|
||||||
) {
|
|
||||||
PluginAbility.entries.forEachIndexed { index, ability ->
|
|
||||||
if (index > 0) {
|
|
||||||
HorizontalDivider(
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val selectedPlugin = state.selectedPlugins[ability]
|
|
||||||
DefaultAbilityPluginSelector(
|
|
||||||
ability = ability,
|
|
||||||
selectedPlugin = selectedPlugin,
|
|
||||||
state = when (ability) {
|
|
||||||
PluginAbility.METADATA -> pluginManager.metadataPlugins
|
|
||||||
PluginAbility.AUDIO -> pluginManager.audioPlugins
|
|
||||||
PluginAbility.LYRICS -> pluginManager.lyricsPlugins
|
|
||||||
PluginAbility.SCROBBLE -> pluginManager.scrobblePlugins
|
|
||||||
},
|
|
||||||
onSelected = { plugin ->
|
|
||||||
pluginManager.setSelectedPlugin(ability, plugin)
|
|
||||||
},
|
|
||||||
onManagePlugins = { }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.plugins.isEmpty()) {
|
if (state.plugins.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Box(
|
Box(
|
||||||
@ -309,85 +245,66 @@ fun PluginScreen(
|
|||||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
items(state.plugins) { plugin ->
|
||||||
Card(
|
val isSelected = state.selectedPlugins.containsValue(plugin)
|
||||||
modifier = Modifier
|
val selectedAbility = state.selectedPlugins
|
||||||
.fillMaxWidth()
|
.entries
|
||||||
.padding(vertical = 4.dp),
|
.firstOrNull { (_, selectedPlugin) -> selectedPlugin.id == plugin.id }
|
||||||
) {
|
?.key
|
||||||
Column(
|
val selectedService = selectedAbility?.let { ability ->
|
||||||
modifier = Modifier.fillMaxWidth()
|
activeServices?.get(ability)
|
||||||
) {
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -397,216 +314,3 @@ fun PluginScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DefaultAbilityPluginSelector(
|
|
||||||
ability: PluginAbility,
|
|
||||||
state: StateFlow<List<PluginEntry>>,
|
|
||||||
selectedPlugin: PluginEntry? = null,
|
|
||||||
onSelected: (PluginEntry?) -> Unit = { },
|
|
||||||
onManagePlugins: () -> Unit = { }
|
|
||||||
) {
|
|
||||||
val plugins by state.collectAsStateWithLifecycle()
|
|
||||||
val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins)
|
|
||||||
val clearText = stringResource(Res.string.settings_plugins_clear)
|
|
||||||
val manageText = stringResource(Res.string.settings_plugins_manage_title)
|
|
||||||
|
|
||||||
val menuItems = buildList {
|
|
||||||
if (plugins.isNotEmpty()) {
|
|
||||||
plugins.forEach { plugin ->
|
|
||||||
val isSelected = selectedPlugin?.name == plugin.name
|
|
||||||
add(
|
|
||||||
AdaptiveMenuItem(
|
|
||||||
label = plugin.name,
|
|
||||||
onClick = { onSelected(plugin) },
|
|
||||||
selected = isSelected,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedPlugin != null) {
|
|
||||||
add(
|
|
||||||
AdaptiveMenuItem(
|
|
||||||
icon = FeatherIcons.AlignLeft,
|
|
||||||
label = clearText,
|
|
||||||
onClick = { onSelected(null) },
|
|
||||||
dividerBefore = true,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
add(
|
|
||||||
AdaptiveMenuItem(
|
|
||||||
label = noPluginsText,
|
|
||||||
onClick = { },
|
|
||||||
enabled = false,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
add(
|
|
||||||
AdaptiveMenuItem(
|
|
||||||
icon = FeatherIcons.ExternalLink,
|
|
||||||
label = manageText,
|
|
||||||
onClick = onManagePlugins,
|
|
||||||
dividerBefore = true,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(12.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
|
|
||||||
color = when (ability) {
|
|
||||||
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f)
|
|
||||||
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f)
|
|
||||||
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f)
|
|
||||||
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = when (ability) {
|
|
||||||
PluginAbility.METADATA -> FeatherIcons.FileText
|
|
||||||
PluginAbility.AUDIO -> FeatherIcons.Music
|
|
||||||
PluginAbility.LYRICS -> FeatherIcons.AlignLeft
|
|
||||||
PluginAbility.SCROBBLE -> FeatherIcons.Activity
|
|
||||||
},
|
|
||||||
contentDescription = stringResource(
|
|
||||||
Res.string.settings_plugins_plugin_content_description,
|
|
||||||
ability.displayLabel()
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(8.dp),
|
|
||||||
tint = when (ability) {
|
|
||||||
PluginAbility.METADATA -> Color(0xFF4CAF50)
|
|
||||||
PluginAbility.AUDIO -> Color(0xFF2196F3)
|
|
||||||
PluginAbility.LYRICS -> Color(0xFFFFC107)
|
|
||||||
PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
Res.string.settings_plugins_default_ability_title,
|
|
||||||
ability.displayLabel()
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
if (selectedPlugin != null) {
|
|
||||||
Text(
|
|
||||||
selectedPlugin.name,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
stringResource(Res.string.settings_plugins_no_selection),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AdaptiveDropdownBottomSheet(
|
|
||||||
items = menuItems,
|
|
||||||
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
|
|
||||||
header = {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
|
|
||||||
color = when (ability) {
|
|
||||||
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f)
|
|
||||||
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f)
|
|
||||||
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f)
|
|
||||||
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = when (ability) {
|
|
||||||
PluginAbility.METADATA -> FeatherIcons.FileText
|
|
||||||
PluginAbility.AUDIO -> FeatherIcons.Music
|
|
||||||
PluginAbility.LYRICS -> FeatherIcons.AlignLeft
|
|
||||||
PluginAbility.SCROBBLE -> FeatherIcons.Activity
|
|
||||||
},
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.padding(8.dp),
|
|
||||||
tint = when (ability) {
|
|
||||||
PluginAbility.METADATA -> Color(0xFF4CAF50)
|
|
||||||
PluginAbility.AUDIO -> Color(0xFF2196F3)
|
|
||||||
PluginAbility.LYRICS -> Color(0xFFFFC107)
|
|
||||||
PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
Res.string.settings_plugins_default_ability_title,
|
|
||||||
ability.displayLabel()
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
selectedPlugin?.let {
|
|
||||||
Text(
|
|
||||||
it.name,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trigger = { onClick ->
|
|
||||||
PrimaryButton(
|
|
||||||
onClick = onClick,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = FeatherIcons.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.padding(0.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
if (selectedPlugin != null) {
|
|
||||||
stringResource(Res.string.settings_plugins_action_change)
|
|
||||||
} else {
|
|
||||||
stringResource(Res.string.settings_plugins_action_select)
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.labelSmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PluginAbility.displayLabel(): String {
|
|
||||||
return when (this) {
|
|
||||||
PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata)
|
|
||||||
PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio)
|
|
||||||
PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics)
|
|
||||||
PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -27,10 +28,14 @@ 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
|
||||||
@ -42,9 +47,6 @@ 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
|
||||||
@ -63,6 +65,11 @@ 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),
|
||||||
@ -114,9 +121,11 @@ internal fun InstallSection(
|
|||||||
shape = RoundedCornerShape(10.dp),
|
shape = RoundedCornerShape(10.dp),
|
||||||
textStyle = MaterialTheme.typography.bodySmall
|
textStyle = MaterialTheme.typography.bodySmall
|
||||||
)
|
)
|
||||||
PrimaryButton(
|
Button(
|
||||||
onClick = onSubmitUrl,
|
onClick = onSubmitUrl,
|
||||||
enabled = !isLoadingUrl,
|
enabled = !isLoadingUrl,
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
modifier = Modifier.height(56.dp)
|
||||||
) {
|
) {
|
||||||
if (isLoadingUrl) {
|
if (isLoadingUrl) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
@ -137,9 +146,10 @@ 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
|
||||||
OutlineButton(
|
OutlinedButton(
|
||||||
onClick = onPickFile,
|
onClick = onPickFile,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(10.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
FeatherIcons.Upload,
|
FeatherIcons.Upload,
|
||||||
@ -152,3 +162,4 @@ internal fun InstallSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,9 +27,13 @@ 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
|
||||||
@ -44,8 +48,6 @@ 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
|
||||||
@ -71,10 +73,24 @@ internal fun PluginCard(
|
|||||||
onLogin: (() -> Unit)? = null,
|
onLogin: (() -> Unit)? = null,
|
||||||
onLogout: (() -> Unit)? = null,
|
onLogout: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Row(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
shape = RoundedCornerShape(14.dp),
|
||||||
.padding(14.dp),
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
),
|
||||||
|
border = BorderStroke(
|
||||||
|
width = 1.dp,
|
||||||
|
color = if (isSelected)
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.outlineVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||||
) {
|
) {
|
||||||
@ -231,7 +247,7 @@ internal fun PluginCard(
|
|||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
GhostIconButton(onClick = onRemove) {
|
IconButton(onClick = onRemove) {
|
||||||
Icon(
|
Icon(
|
||||||
FeatherIcons.Trash2,
|
FeatherIcons.Trash2,
|
||||||
contentDescription = stringResource(Res.string.plugin_action_remove),
|
contentDescription = stringResource(Res.string.plugin_action_remove),
|
||||||
@ -247,11 +263,12 @@ internal fun PluginCard(
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
authAction?.let { (action, label) ->
|
authAction?.let { (action, label) ->
|
||||||
OutlineButton(onClick = action) {
|
TextButton(onClick = action) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(label),
|
text = stringResource(label),
|
||||||
style = MaterialTheme.typography.labelLarge
|
style = MaterialTheme.typography.labelLarge
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -267,3 +284,4 @@ private fun PluginAbility.displayLabel(): String {
|
|||||||
PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble)
|
PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,7 @@ import dev.krtirtho.spotube.PlatformType
|
|||||||
import dev.krtirtho.spotube.getPlatform
|
import dev.krtirtho.spotube.getPlatform
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
||||||
import spotube.composeapp.generated.resources.*
|
import spotube.composeapp.generated.resources.*
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.appearanceSection
|
import dev.krtirtho.spotube.modules.settings.sections.appearanceSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.cacheSection
|
import dev.krtirtho.spotube.modules.settings.sections.cacheSection
|
||||||
@ -53,8 +54,9 @@ import org.koin.compose.koinInject
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
fun SettingsScreen(pluginManager: PluginManager, settingsViewModel: SettingsViewModel) {
|
||||||
val navigatorCommands = koinInject<NavigationCommands>()
|
val navigatorCommands = koinInject<NavigationCommands>()
|
||||||
|
val pluginState by pluginManager.state.collectAsStateWithLifecycle()
|
||||||
val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle()
|
val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle()
|
||||||
val platformType = remember { getPlatform().type }
|
val platformType = remember { getPlatform().type }
|
||||||
val isDesktopPlatform = platformType == PlatformType.Windows ||
|
val isDesktopPlatform = platformType == PlatformType.Windows ||
|
||||||
@ -88,6 +90,8 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
|||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
) {
|
) {
|
||||||
pluginsSection(
|
pluginsSection(
|
||||||
|
pluginManager = pluginManager,
|
||||||
|
pluginState = pluginState,
|
||||||
navigatorCommands = navigatorCommands,
|
navigatorCommands = navigatorCommands,
|
||||||
)
|
)
|
||||||
if (settingsState != null) {
|
if (settingsState != null) {
|
||||||
|
|||||||
@ -24,13 +24,18 @@ 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.material3.Icon
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
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 androidx.compose.material3.Switch
|
||||||
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
|
||||||
@ -41,16 +46,8 @@ 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
|
||||||
|
|
||||||
@ -67,7 +64,7 @@ internal fun SwitchSettingCard(
|
|||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
icon = icon,
|
icon = icon,
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Toggle(
|
Switch(
|
||||||
checked = checked,
|
checked = checked,
|
||||||
onCheckedChange = onCheckedChange,
|
onCheckedChange = onCheckedChange,
|
||||||
)
|
)
|
||||||
@ -110,13 +107,8 @@ internal fun <T> SelectionSettingCard(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
trigger = { onClick ->
|
trigger = { onClick ->
|
||||||
OutlineButton(onClick = onClick) {
|
TextButton(onClick = onClick) {
|
||||||
Text(optionLabel(selectedOption))
|
Text(optionLabel(selectedOption))
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxArrowDown4,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(14.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
filter = filter,
|
filter = filter,
|
||||||
@ -130,47 +122,51 @@ internal fun <T> SelectionSettingCard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!isWideLayout && isDialogOpen) {
|
if (!isWideLayout && isDialogOpen) {
|
||||||
ThemedDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { isDialogOpen = false },
|
onDismissRequest = { isDialogOpen = false },
|
||||||
title = {
|
title = {
|
||||||
Text(dialogTitle, style = MaterialTheme.typography.titleLarge)
|
Text(dialogTitle)
|
||||||
},
|
},
|
||||||
actions = {
|
text = {
|
||||||
OutlineButton(onClick = { isDialogOpen = false }) {
|
Column(
|
||||||
Text(stringResource(Res.string.settings_action_close))
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
}
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
},
|
) {
|
||||||
) {
|
options.forEach { option ->
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
val isSelected = option == selectedOption
|
||||||
options.forEach { option ->
|
Row(
|
||||||
val isSelected = option == selectedOption
|
modifier = Modifier
|
||||||
Row(
|
.fillMaxWidth()
|
||||||
modifier = Modifier
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.fillMaxWidth()
|
.clickable {
|
||||||
.clip(RoundedCornerShape(10.dp))
|
onOptionSelected(option)
|
||||||
.clickable {
|
isDialogOpen = false
|
||||||
onOptionSelected(option)
|
}
|
||||||
isDialogOpen = false
|
.padding(vertical = 4.dp),
|
||||||
}
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
.padding(vertical = 4.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
RadioButton(
|
||||||
) {
|
selected = isSelected,
|
||||||
Radio(
|
onClick = {
|
||||||
selected = isSelected,
|
onOptionSelected(option)
|
||||||
onClick = {
|
isDialogOpen = false
|
||||||
onOptionSelected(option)
|
}
|
||||||
isDialogOpen = false
|
)
|
||||||
}
|
Text(
|
||||||
)
|
text = optionLabel(option),
|
||||||
Text(
|
style = MaterialTheme.typography.bodyMedium
|
||||||
text = optionLabel(option),
|
)
|
||||||
style = MaterialTheme.typography.bodyMedium
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { isDialogOpen = false }) {
|
||||||
|
Text(stringResource(Res.string.settings_action_close))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -238,13 +234,39 @@ internal fun TextInputSettingCard(
|
|||||||
val normalizedValue = normalize(draft)
|
val normalizedValue = normalize(draft)
|
||||||
val errorMessage = validate(normalizedValue)
|
val errorMessage = validate(normalizedValue)
|
||||||
|
|
||||||
ThemedDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { isDialogOpen = false },
|
onDismissRequest = { isDialogOpen = false },
|
||||||
title = {
|
title = {
|
||||||
Text(dialogTitle, style = MaterialTheme.typography.titleLarge)
|
Text(dialogTitle)
|
||||||
},
|
},
|
||||||
actions = {
|
text = {
|
||||||
PrimaryButton(
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
dialogDescription?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = { draft = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = if (placeholder.isNotEmpty()) {
|
||||||
|
{ Text(placeholder) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
isError = errorMessage != null,
|
||||||
|
supportingText = errorMessage?.let { message ->
|
||||||
|
{ Text(message) }
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (errorMessage == null) {
|
if (errorMessage == null) {
|
||||||
onValueSaved(normalizedValue)
|
onValueSaved(normalizedValue)
|
||||||
@ -254,36 +276,13 @@ 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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,11 +17,26 @@
|
|||||||
|
|
||||||
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 dev.krtirtho.spotube.core.ui.base.ListRowTile
|
import androidx.compose.ui.draw.alpha
|
||||||
|
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
|
||||||
@ -33,32 +48,54 @@ fun SettingCardItem(
|
|||||||
title: String,
|
title: String,
|
||||||
subtitle: String? = null,
|
subtitle: String? = null,
|
||||||
icon: (@Composable () -> Unit)? = null,
|
icon: (@Composable () -> Unit)? = null,
|
||||||
trailingContent: (@Composable () -> Unit)? = null,
|
trailingContent: (@Composable RowScope.() -> Unit)? = null,
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
ListRowTile(
|
Card(
|
||||||
selected = false,
|
modifier = modifier
|
||||||
enabled = enabled,
|
.fillMaxWidth()
|
||||||
onClick = onClick,
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
modifier = modifier,
|
.clip(RoundedCornerShape(12.dp))
|
||||||
leading = icon,
|
.clickable(onClick = onClick, enabled = enabled)
|
||||||
title = {
|
.then(if (!enabled) Modifier.alpha(0.5f) else Modifier),
|
||||||
Text(
|
shape = RoundedCornerShape(12.dp),
|
||||||
title,
|
colors = CardDefaults.cardColors(
|
||||||
style = MaterialTheme.typography.labelLarge,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
),
|
||||||
)
|
border = BorderStroke(
|
||||||
},
|
width = 1.dp,
|
||||||
subtitle = subtitle?.let {
|
color = MaterialTheme.colorScheme.outlineVariant
|
||||||
{
|
),
|
||||||
Text(
|
) {
|
||||||
it,
|
Row(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
modifier = Modifier
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
.fillMaxWidth()
|
||||||
)
|
.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
icon()
|
||||||
}
|
}
|
||||||
},
|
|
||||||
trailing = trailingContent,
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
)
|
Text(
|
||||||
}
|
title,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.Gray,
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trailingContent?.invoke(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,14 +27,19 @@ 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
|
||||||
@ -48,10 +53,6 @@ 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
|
||||||
@ -66,43 +67,41 @@ internal fun LazyListScope.appearanceSection(
|
|||||||
settingsViewModel: SettingsViewModel,
|
settingsViewModel: SettingsViewModel,
|
||||||
) {
|
) {
|
||||||
settingsSectionHeader(Res.string.settings_section_appearance)
|
settingsSectionHeader(Res.string.settings_section_appearance)
|
||||||
settingsSectionCard(
|
|
||||||
items = listOf(
|
item {
|
||||||
{
|
SelectionSettingCard(
|
||||||
SelectionSettingCard(
|
title = stringResource(Res.string.settings_theme_title),
|
||||||
title = stringResource(Res.string.settings_theme_title),
|
subtitle = stringResource(
|
||||||
subtitle = stringResource(
|
Res.string.settings_theme_subtitle_current,
|
||||||
Res.string.settings_theme_subtitle_current,
|
settings.theme.displayLabel()
|
||||||
settings.theme.displayLabel()
|
),
|
||||||
),
|
icon = {
|
||||||
icon = {
|
SettingsItemIcon(FeatherIcons.Monitor, stringResource(Res.string.settings_theme_title))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
AccentColorSettingCard(
|
|
||||||
selectedAccent = settings.accentColor,
|
|
||||||
icon = {
|
|
||||||
SettingsItemIcon(FeatherIcons.Droplet, stringResource(Res.string.settings_accent_title))
|
|
||||||
},
|
|
||||||
onColorSaved = { accent ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(accentColor = accent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
|
selectedOption = settings.theme,
|
||||||
|
options = Theme.entries,
|
||||||
|
optionLabel = { it.displayLabel() },
|
||||||
|
onOptionSelected = { theme ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(theme = theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
AccentColorSettingCard(
|
||||||
|
selectedAccent = settings.accentColor,
|
||||||
|
icon = {
|
||||||
|
SettingsItemIcon(FeatherIcons.Droplet, stringResource(Res.string.settings_accent_title))
|
||||||
|
},
|
||||||
|
onColorSaved = { accent ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(accentColor = accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -156,13 +155,75 @@ private fun AccentColorSettingCard(
|
|||||||
if (isDialogOpen) {
|
if (isDialogOpen) {
|
||||||
var draftAccent by remember(selectedAccent, isDialogOpen) { mutableStateOf(selectedAccent) }
|
var draftAccent by remember(selectedAccent, isDialogOpen) { mutableStateOf(selectedAccent) }
|
||||||
|
|
||||||
ThemedDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { isDialogOpen = false },
|
onDismissRequest = { isDialogOpen = false },
|
||||||
title = {
|
title = {
|
||||||
Text(stringResource(Res.string.settings_accent_dialog_title), style = MaterialTheme.typography.titleLarge)
|
Text(stringResource(Res.string.settings_accent_dialog_title))
|
||||||
},
|
},
|
||||||
actions = {
|
text = {
|
||||||
PrimaryButton(
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 420.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.settings_accent_dialog_description),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
AccentThemePreview(
|
||||||
|
title = stringResource(Res.string.settings_preview_light),
|
||||||
|
accent = draftAccent.toLightColor(),
|
||||||
|
background = Color(0xFFFFFFFF),
|
||||||
|
textColor = Color(0xFF121212),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
AccentThemePreview(
|
||||||
|
title = stringResource(Res.string.settings_preview_dark),
|
||||||
|
accent = draftAccent.toDarkColor(),
|
||||||
|
background = Color(0xFF121212),
|
||||||
|
textColor = Color(0xFFEDEDED),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
AccentColors.entries.forEach { option ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { draftAccent = option }
|
||||||
|
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = option == draftAccent,
|
||||||
|
onClick = { draftAccent = option }
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = option.displayLabel(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
AccentDualPreview(
|
||||||
|
lightAccent = option.toLightColor(),
|
||||||
|
darkAccent = option.toDarkColor(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
onColorSaved(draftAccent)
|
onColorSaved(draftAccent)
|
||||||
isDialogOpen = false
|
isDialogOpen = false
|
||||||
@ -170,66 +231,13 @@ 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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,12 +19,14 @@ 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
|
||||||
@ -39,84 +41,83 @@ internal fun LazyListScope.cacheSection(
|
|||||||
settingsViewModel: SettingsViewModel,
|
settingsViewModel: SettingsViewModel,
|
||||||
) {
|
) {
|
||||||
settingsSectionHeader(Res.string.settings_section_caching)
|
settingsSectionHeader(Res.string.settings_section_caching)
|
||||||
settingsSectionCard(
|
|
||||||
items = listOf(
|
|
||||||
{
|
|
||||||
SwitchSettingCard(
|
|
||||||
title = stringResource(Res.string.settings_enable_music_caching_title),
|
|
||||||
subtitle = stringResource(Res.string.settings_enable_music_caching_subtitle),
|
|
||||||
icon = {
|
|
||||||
SettingsItemIcon(
|
|
||||||
FeatherIcons.HardDrive,
|
|
||||||
stringResource(Res.string.settings_enable_music_caching_title)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
checked = settings.enableMusicCaching,
|
|
||||||
onCheckedChange = { enabled ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(enableMusicCaching = enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
CacheFolderSettingCard(
|
|
||||||
enabled = settings.enableMusicCaching,
|
|
||||||
folder = settings.cacheFolder,
|
|
||||||
onFolderSelected = { folder ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(cacheFolder = folder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
val error_whole_number = stringResource(Res.string.settings_error_whole_number)
|
|
||||||
val error_cache_range = stringResource(Res.string.settings_error_cache_size_range)
|
|
||||||
|
|
||||||
TextInputSettingCard(
|
item {
|
||||||
enabled = settings.enableMusicCaching,
|
SwitchSettingCard(
|
||||||
title = stringResource(Res.string.settings_cache_size_limit_title),
|
title = stringResource(Res.string.settings_enable_music_caching_title),
|
||||||
subtitle = when {
|
subtitle = stringResource(Res.string.settings_enable_music_caching_subtitle),
|
||||||
settings.cacheSizeLimitMB <= 0L -> stringResource(Res.string.settings_cache_size_limit_unlimited)
|
icon = {
|
||||||
else -> stringResource(
|
SettingsItemIcon(
|
||||||
Res.string.settings_cache_size_limit_current,
|
FeatherIcons.HardDrive,
|
||||||
settings.cacheSizeLimitMB
|
stringResource(Res.string.settings_enable_music_caching_title)
|
||||||
)
|
|
||||||
},
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
checked = settings.enableMusicCaching,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(enableMusicCaching = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
CacheFolderSettingCard(
|
||||||
|
enabled = settings.enableMusicCaching,
|
||||||
|
folder = settings.cacheFolder,
|
||||||
|
onFolderSelected = { folder ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(cacheFolder = folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
val error_whole_number = stringResource(Res.string.settings_error_whole_number)
|
||||||
|
val error_cache_range = stringResource(Res.string.settings_error_cache_size_range)
|
||||||
|
|
||||||
|
TextInputSettingCard(
|
||||||
|
enabled = settings.enableMusicCaching,
|
||||||
|
title = stringResource(Res.string.settings_cache_size_limit_title),
|
||||||
|
subtitle = when {
|
||||||
|
settings.cacheSizeLimitMB <= 0L -> stringResource(Res.string.settings_cache_size_limit_unlimited)
|
||||||
|
else -> stringResource(
|
||||||
|
Res.string.settings_cache_size_limit_current,
|
||||||
|
settings.cacheSizeLimitMB
|
||||||
|
)
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
SettingsItemIcon(
|
||||||
|
FeatherIcons.HardDrive,
|
||||||
|
stringResource(Res.string.settings_cache_size_limit_title)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
value = when {
|
||||||
|
settings.cacheSizeLimitMB <= 0L -> ""
|
||||||
|
else -> settings.cacheSizeLimitMB.toString()
|
||||||
|
},
|
||||||
|
dialogDescription = stringResource(Res.string.settings_cache_size_limit_description),
|
||||||
|
placeholder = stringResource(Res.string.settings_cache_size_limit_placeholder),
|
||||||
|
normalize = { it.trim() },
|
||||||
|
validate = { value ->
|
||||||
|
if (value.isBlank()) null
|
||||||
|
else {
|
||||||
|
val mb = value.toLongOrNull()
|
||||||
|
when {
|
||||||
|
mb == null -> error_whole_number
|
||||||
|
mb < 0L -> error_cache_range
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onValueSaved = { value ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(cacheSizeLimitMB = value.toLongOrNull()?.coerceAtLeast(0L) ?: 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -146,7 +147,7 @@ private fun CacheFolderSettingCard(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
GhostIconButton(
|
IconButton(
|
||||||
onClick = { pickerLauncher.launch() },
|
onClick = { pickerLauncher.launch() },
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -32,45 +32,43 @@ internal fun LazyListScope.desktopSection(
|
|||||||
settingsViewModel: SettingsViewModel,
|
settingsViewModel: SettingsViewModel,
|
||||||
) {
|
) {
|
||||||
settingsSectionHeader(Res.string.settings_section_desktop)
|
settingsSectionHeader(Res.string.settings_section_desktop)
|
||||||
settingsSectionCard(
|
|
||||||
items = listOf(
|
item {
|
||||||
{
|
SwitchSettingCard(
|
||||||
SwitchSettingCard(
|
title = stringResource(Res.string.settings_desktop_minimize_title),
|
||||||
title = stringResource(Res.string.settings_desktop_minimize_title),
|
subtitle = stringResource(Res.string.settings_desktop_minimize_subtitle),
|
||||||
subtitle = stringResource(Res.string.settings_desktop_minimize_subtitle),
|
icon = {
|
||||||
icon = {
|
SettingsItemIcon(
|
||||||
SettingsItemIcon(
|
FeatherIcons.Minimize2,
|
||||||
FeatherIcons.Minimize2,
|
stringResource(Res.string.settings_desktop_minimize_title)
|
||||||
stringResource(Res.string.settings_desktop_minimize_title)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
checked = settings.minimizeToTray,
|
|
||||||
onCheckedChange = { enabled ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(minimizeToTray = enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
SwitchSettingCard(
|
|
||||||
title = stringResource(Res.string.settings_desktop_discord_title),
|
|
||||||
subtitle = stringResource(Res.string.settings_desktop_discord_subtitle),
|
|
||||||
icon = {
|
|
||||||
SettingsItemIcon(
|
|
||||||
FeatherIcons.Activity,
|
|
||||||
stringResource(Res.string.settings_desktop_discord_title)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
checked = settings.discordRichPresence,
|
|
||||||
onCheckedChange = { enabled ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(discordRichPresence = enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
checked = settings.minimizeToTray,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(minimizeToTray = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SwitchSettingCard(
|
||||||
|
title = stringResource(Res.string.settings_desktop_discord_title),
|
||||||
|
subtitle = stringResource(Res.string.settings_desktop_discord_subtitle),
|
||||||
|
icon = {
|
||||||
|
SettingsItemIcon(
|
||||||
|
FeatherIcons.Activity,
|
||||||
|
stringResource(Res.string.settings_desktop_discord_title)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
checked = settings.discordRichPresence,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(discordRichPresence = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,9 +26,12 @@ 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
|
||||||
@ -46,10 +49,6 @@ 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
|
||||||
@ -69,73 +68,73 @@ internal fun LazyListScope.downloadsSection(
|
|||||||
)
|
)
|
||||||
|
|
||||||
settingsSectionHeader(Res.string.settings_section_downloads)
|
settingsSectionHeader(Res.string.settings_section_downloads)
|
||||||
settingsSectionCard(
|
|
||||||
items = listOf(
|
item {
|
||||||
{
|
DownloadFolderSettingCard(
|
||||||
DownloadFolderSettingCard(
|
folder = settings.overloadedDownloadFolder,
|
||||||
folder = settings.overloadedDownloadFolder,
|
onFolderSelected = { folder ->
|
||||||
onFolderSelected = { folder ->
|
settingsViewModel.updateSettings {
|
||||||
settingsViewModel.updateSettings {
|
copy(overloadedDownloadFolder = folder)
|
||||||
copy(overloadedDownloadFolder = folder)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
LocalMediaFoldersSettingCard(
|
|
||||||
folders = settings.localMediaFolders,
|
|
||||||
onFoldersSaved = { folders ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(localMediaFolders = folders)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
SelectionSettingCard(
|
|
||||||
title = stringResource(Res.string.settings_download_format_title),
|
|
||||||
subtitle = stringResource(
|
|
||||||
Res.string.settings_download_format_subtitle_current,
|
|
||||||
settings.downloadMusicFormat.displayLabel()
|
|
||||||
),
|
|
||||||
icon = {
|
|
||||||
SettingsItemIcon(FeatherIcons.Disc, stringResource(Res.string.settings_download_format_title))
|
|
||||||
},
|
|
||||||
selectedOption = settings.downloadMusicFormat,
|
|
||||||
options = downloadFormats,
|
|
||||||
optionLabel = { it.displayLabel() },
|
|
||||||
onOptionSelected = { format ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(
|
|
||||||
downloadMusicFormat = format,
|
|
||||||
downloadMusicQuality = format.resolveQuality(downloadMusicQuality),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
SelectionSettingCard(
|
|
||||||
title = stringResource(Res.string.settings_download_quality_title),
|
|
||||||
subtitle = stringResource(
|
|
||||||
Res.string.settings_subtitle_current,
|
|
||||||
settings.downloadMusicQuality.displayLabel()
|
|
||||||
),
|
|
||||||
icon = {
|
|
||||||
SettingsItemIcon(FeatherIcons.Sliders, stringResource(Res.string.settings_download_quality_title))
|
|
||||||
},
|
|
||||||
selectedOption = settings.downloadMusicQuality,
|
|
||||||
options = downloadQualities,
|
|
||||||
optionLabel = { it.displayLabel() },
|
|
||||||
onOptionSelected = { quality ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(downloadMusicQuality = quality)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
optionLabel = { it.displayLabel() },
|
||||||
|
onOptionSelected = { format ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(
|
||||||
|
downloadMusicFormat = format,
|
||||||
|
downloadMusicQuality = format.resolveQuality(downloadMusicQuality),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SelectionSettingCard(
|
||||||
|
title = stringResource(Res.string.settings_download_quality_title),
|
||||||
|
subtitle = stringResource(
|
||||||
|
Res.string.settings_subtitle_current,
|
||||||
|
settings.downloadMusicQuality.displayLabel()
|
||||||
|
),
|
||||||
|
icon = {
|
||||||
|
SettingsItemIcon(FeatherIcons.Sliders, stringResource(Res.string.settings_download_quality_title))
|
||||||
|
},
|
||||||
|
selectedOption = settings.downloadMusicQuality,
|
||||||
|
options = downloadQualities,
|
||||||
|
optionLabel = { it.displayLabel() },
|
||||||
|
onOptionSelected = { quality ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(downloadMusicQuality = quality)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -161,7 +160,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 = {
|
||||||
GhostIconButton(onClick = { pickerLauncher.launch() }) {
|
IconButton(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)
|
||||||
@ -200,7 +199,7 @@ private fun LocalMediaFoldersSettingCard(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
OutlineButton(onClick = { isDialogOpen = true }) {
|
TextButton(onClick = { isDialogOpen = true }) {
|
||||||
Text(stringResource(Res.string.settings_local_media_folders_manage))
|
Text(stringResource(Res.string.settings_local_media_folders_manage))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -236,16 +235,76 @@ private fun LocalMediaFoldersDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ThemedDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(stringResource(Res.string.settings_local_media_folders_title))
|
||||||
text = stringResource(Res.string.settings_local_media_folders_title),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
actions = {
|
text = {
|
||||||
PrimaryButton(
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.settings_local_media_folders_description),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
TextButton(onClick = { pickerLauncher.launch() }) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(FeatherIcons.PlusSquare, contentDescription = null)
|
||||||
|
Text(stringResource(Res.string.settings_local_media_folders_add_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draftFolders.isEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.settings_local_media_folders_none_added),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
draftFolders.forEachIndexed { index, folder ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = folder,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (index in draftFolders.indices) {
|
||||||
|
draftFolders.removeAt(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = FeatherIcons.Trash2,
|
||||||
|
contentDescription = stringResource(
|
||||||
|
Res.string.settings_local_media_folders_remove_action,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct())
|
onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct())
|
||||||
onDismiss()
|
onDismiss()
|
||||||
@ -253,69 +312,12 @@ 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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,61 +34,59 @@ internal fun LazyListScope.languageRegionSection(
|
|||||||
settingsViewModel: SettingsViewModel,
|
settingsViewModel: SettingsViewModel,
|
||||||
) {
|
) {
|
||||||
settingsSectionHeader(Res.string.settings_section_language_region)
|
settingsSectionHeader(Res.string.settings_section_language_region)
|
||||||
settingsSectionCard(
|
|
||||||
items = listOf(
|
item {
|
||||||
{
|
SelectionSettingCard(
|
||||||
SelectionSettingCard(
|
title = stringResource(Res.string.settings_language_title),
|
||||||
title = stringResource(Res.string.settings_language_title),
|
subtitle = stringResource(
|
||||||
subtitle = stringResource(
|
Res.string.settings_language_subtitle_current,
|
||||||
Res.string.settings_language_subtitle_current,
|
settings.language.displayName
|
||||||
settings.language.displayName
|
),
|
||||||
),
|
icon = {
|
||||||
icon = {
|
SettingsItemIcon(
|
||||||
SettingsItemIcon(
|
FeatherIcons.Globe,
|
||||||
FeatherIcons.Globe,
|
stringResource(Res.string.settings_language_title)
|
||||||
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,
|
||||||
SelectionSettingCard(
|
options = SupportedLanguages.entries,
|
||||||
title = stringResource(Res.string.settings_country_title),
|
optionLabel = {
|
||||||
subtitle = stringResource(
|
stringResource(Res.string.settings_option_name_and_code, it.displayName, it.locale)
|
||||||
Res.string.settings_country_subtitle_current,
|
|
||||||
settings.country.displayName
|
|
||||||
),
|
|
||||||
icon = {
|
|
||||||
SettingsItemIcon(
|
|
||||||
FeatherIcons.MapPin,
|
|
||||||
stringResource(Res.string.settings_country_title)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
selectedOption = settings.country,
|
|
||||||
options = CountryCode.entries,
|
|
||||||
optionLabel = {
|
|
||||||
stringResource(Res.string.settings_option_name_and_code, it.displayName, it.code)
|
|
||||||
},
|
|
||||||
onOptionSelected = { value ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(country = value)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
filter = { item, query -> item.label.contains(query, ignoreCase = true) },
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
|
onOptionSelected = { value ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(language = value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filter = { item, query -> item.label.contains(query, ignoreCase = true) },
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SelectionSettingCard(
|
||||||
|
title = stringResource(Res.string.settings_country_title),
|
||||||
|
subtitle = stringResource(
|
||||||
|
Res.string.settings_country_subtitle_current,
|
||||||
|
settings.country.displayName
|
||||||
|
),
|
||||||
|
icon = {
|
||||||
|
SettingsItemIcon(
|
||||||
|
FeatherIcons.MapPin,
|
||||||
|
stringResource(Res.string.settings_country_title)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -43,117 +43,118 @@ internal fun LazyListScope.playbackSection(
|
|||||||
)
|
)
|
||||||
|
|
||||||
settingsSectionHeader(Res.string.settings_section_playback)
|
settingsSectionHeader(Res.string.settings_section_playback)
|
||||||
settingsSectionCard(
|
|
||||||
items = listOf(
|
|
||||||
{
|
|
||||||
SelectionSettingCard(
|
|
||||||
title = stringResource(Res.string.settings_streaming_format_title),
|
|
||||||
subtitle = stringResource(
|
|
||||||
Res.string.settings_streaming_format_subtitle_current,
|
|
||||||
settings.streamingMusicFormat.displayLabel()
|
|
||||||
),
|
|
||||||
icon = {
|
|
||||||
SettingsItemIcon(FeatherIcons.Radio, stringResource(Res.string.settings_streaming_format_title))
|
|
||||||
},
|
|
||||||
selectedOption = settings.streamingMusicFormat,
|
|
||||||
options = streamingFormats,
|
|
||||||
optionLabel = { it.displayLabel() },
|
|
||||||
onOptionSelected = { format ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(
|
|
||||||
streamingMusicFormat = format,
|
|
||||||
streamingMusicQuality = format.resolveQuality(streamingMusicQuality),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
SwitchSettingCard(
|
|
||||||
title = stringResource(Res.string.settings_enable_endless_playback_title),
|
|
||||||
subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle),
|
|
||||||
icon = {
|
|
||||||
SettingsItemIcon(
|
|
||||||
FeatherIcons.Repeat,
|
|
||||||
stringResource(Res.string.settings_enable_endless_playback_title)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
checked = settings.enableEndlessPlayback,
|
|
||||||
onCheckedChange = { enabled ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(enableEndlessPlayback = enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
SwitchSettingCard(
|
|
||||||
title = stringResource(Res.string.settings_enable_connect_title),
|
|
||||||
subtitle = stringResource(Res.string.settings_enable_connect_subtitle),
|
|
||||||
icon = {
|
|
||||||
SettingsItemIcon(FeatherIcons.Cast, stringResource(Res.string.settings_enable_connect_title))
|
|
||||||
},
|
|
||||||
checked = settings.enableConnect,
|
|
||||||
onCheckedChange = { enabled ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(enableConnect = enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
val error_whole_number = stringResource(Res.string.settings_error_whole_number)
|
|
||||||
val error_port_range = stringResource(Res.string.settings_error_port_range)
|
|
||||||
|
|
||||||
TextInputSettingCard(
|
item {
|
||||||
title = stringResource(Res.string.settings_playback_port_title),
|
SelectionSettingCard(
|
||||||
subtitle = stringResource(
|
title = stringResource(Res.string.settings_streaming_format_title),
|
||||||
Res.string.settings_playback_port_subtitle_current,
|
subtitle = stringResource(
|
||||||
settings.playbackProxyServerPort
|
Res.string.settings_streaming_format_subtitle_current,
|
||||||
),
|
settings.streamingMusicFormat.displayLabel()
|
||||||
icon = {
|
),
|
||||||
SettingsItemIcon(FeatherIcons.Server, stringResource(Res.string.settings_playback_port_title))
|
icon = {
|
||||||
},
|
SettingsItemIcon(FeatherIcons.Radio, stringResource(Res.string.settings_streaming_format_title))
|
||||||
value = settings.playbackProxyServerPort.toString(),
|
},
|
||||||
dialogDescription = stringResource(Res.string.settings_playback_port_description),
|
selectedOption = settings.streamingMusicFormat,
|
||||||
placeholder = stringResource(Res.string.settings_playback_port_placeholder),
|
options = streamingFormats,
|
||||||
normalize = { it.trim() },
|
optionLabel = { it.displayLabel() },
|
||||||
validate = { value ->
|
onOptionSelected = { format ->
|
||||||
val port = value.toIntOrNull()
|
settingsViewModel.updateSettings {
|
||||||
when {
|
copy(
|
||||||
port == null -> error_whole_number
|
streamingMusicFormat = format,
|
||||||
port !in 1..65535 -> error_port_range
|
streamingMusicQuality = format.resolveQuality(streamingMusicQuality),
|
||||||
else -> null
|
)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
onValueSaved = { value ->
|
)
|
||||||
settingsViewModel.updateSettings {
|
}
|
||||||
copy(playbackProxyServerPort = value.toInt())
|
|
||||||
}
|
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 ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(enableEndlessPlayback = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SwitchSettingCard(
|
||||||
|
title = stringResource(Res.string.settings_enable_connect_title),
|
||||||
|
subtitle = stringResource(Res.string.settings_enable_connect_subtitle),
|
||||||
|
icon = {
|
||||||
|
SettingsItemIcon(FeatherIcons.Cast, stringResource(Res.string.settings_enable_connect_title))
|
||||||
|
},
|
||||||
|
checked = settings.enableConnect,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(enableConnect = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
val error_whole_number = stringResource(Res.string.settings_error_whole_number)
|
||||||
|
val error_port_range = stringResource(Res.string.settings_error_port_range)
|
||||||
|
|
||||||
|
TextInputSettingCard(
|
||||||
|
title = stringResource(Res.string.settings_playback_port_title),
|
||||||
|
subtitle = stringResource(
|
||||||
|
Res.string.settings_playback_port_subtitle_current,
|
||||||
|
settings.playbackProxyServerPort
|
||||||
|
),
|
||||||
|
icon = {
|
||||||
|
SettingsItemIcon(FeatherIcons.Server, stringResource(Res.string.settings_playback_port_title))
|
||||||
|
},
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,48 +17,369 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.modules.settings.sections
|
package dev.krtirtho.spotube.modules.settings.sections
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
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.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
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.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import compose.icons.FeatherIcons
|
import compose.icons.FeatherIcons
|
||||||
|
import compose.icons.feathericons.Activity
|
||||||
|
import compose.icons.feathericons.AlignLeft
|
||||||
|
import compose.icons.feathericons.Check
|
||||||
import compose.icons.feathericons.ChevronRight
|
import compose.icons.feathericons.ChevronRight
|
||||||
|
import compose.icons.feathericons.ExternalLink
|
||||||
|
import compose.icons.feathericons.FileText
|
||||||
|
import compose.icons.feathericons.Music
|
||||||
import compose.icons.feathericons.Package
|
import compose.icons.feathericons.Package
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
|
import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
|
||||||
|
import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem
|
||||||
|
import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
|
||||||
import spotube.composeapp.generated.resources.*
|
import spotube.composeapp.generated.resources.*
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
||||||
|
import dev.krtirtho.spotube.modules.plugin.PluginManagerStates
|
||||||
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
|
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
internal fun LazyListScope.pluginsSection(
|
@Composable
|
||||||
navigatorCommands: NavigationCommands
|
fun DefaultAbilityPluginSelector(
|
||||||
|
ability: PluginAbility,
|
||||||
|
state: StateFlow<List<PluginEntry>>,
|
||||||
|
selectedPlugin: PluginEntry? = null,
|
||||||
|
onSelected: (PluginEntry?) -> Unit = { },
|
||||||
|
onManagePlugins: () -> Unit = { }
|
||||||
) {
|
) {
|
||||||
settingsSectionHeader(Res.string.settings_section_plugins)
|
val plugins by state.collectAsStateWithLifecycle()
|
||||||
settingsSectionCard(
|
val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins)
|
||||||
items = listOf {
|
val clearText = stringResource(Res.string.settings_plugins_clear)
|
||||||
SettingCardItem(
|
val manageText = stringResource(Res.string.settings_plugins_manage_title)
|
||||||
title = stringResource(Res.string.settings_plugins_manage_title),
|
|
||||||
subtitle = stringResource(Res.string.settings_plugins_manage_subtitle),
|
val menuItems = buildList {
|
||||||
icon = {
|
if (plugins.isNotEmpty()) {
|
||||||
SettingsItemIcon(
|
plugins.forEach { plugin ->
|
||||||
FeatherIcons.Package,
|
val isSelected = selectedPlugin?.name == plugin.name
|
||||||
stringResource(Res.string.settings_section_plugins),
|
add(
|
||||||
Color(0xFFFF9800)
|
AdaptiveMenuItem(
|
||||||
|
label = plugin.name,
|
||||||
|
onClick = { onSelected(plugin) },
|
||||||
|
selected = isSelected,
|
||||||
)
|
)
|
||||||
},
|
)
|
||||||
trailingContent = {
|
}
|
||||||
Icon(
|
|
||||||
imageVector = FeatherIcons.ChevronRight,
|
if (selectedPlugin != null) {
|
||||||
contentDescription = stringResource(Res.string.settings_plugins_manage_title),
|
add(
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
AdaptiveMenuItem(
|
||||||
|
icon = FeatherIcons.AlignLeft,
|
||||||
|
label = clearText,
|
||||||
|
onClick = { onSelected(null) },
|
||||||
|
dividerBefore = true,
|
||||||
)
|
)
|
||||||
},
|
)
|
||||||
onClick = {
|
}
|
||||||
navigatorCommands.navigateTo(Routes.Plugins)
|
} else {
|
||||||
}
|
add(
|
||||||
|
AdaptiveMenuItem(
|
||||||
|
label = noPluginsText,
|
||||||
|
onClick = { },
|
||||||
|
enabled = false,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
add(
|
||||||
|
AdaptiveMenuItem(
|
||||||
|
icon = FeatherIcons.ExternalLink,
|
||||||
|
label = manageText,
|
||||||
|
onClick = onManagePlugins,
|
||||||
|
dividerBefore = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
),
|
||||||
|
border = BorderStroke(
|
||||||
|
width = 1.dp,
|
||||||
|
color = if (selectedPlugin != null) {
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outlineVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
|
||||||
|
color = when (ability) {
|
||||||
|
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f)
|
||||||
|
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f)
|
||||||
|
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f)
|
||||||
|
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (ability) {
|
||||||
|
PluginAbility.METADATA -> FeatherIcons.FileText
|
||||||
|
PluginAbility.AUDIO -> FeatherIcons.Music
|
||||||
|
PluginAbility.LYRICS -> FeatherIcons.AlignLeft
|
||||||
|
PluginAbility.SCROBBLE -> FeatherIcons.Activity
|
||||||
|
},
|
||||||
|
contentDescription = stringResource(
|
||||||
|
Res.string.settings_plugins_plugin_content_description,
|
||||||
|
ability.displayLabel()
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
tint = when (ability) {
|
||||||
|
PluginAbility.METADATA -> Color(0xFF4CAF50)
|
||||||
|
PluginAbility.AUDIO -> Color(0xFF2196F3)
|
||||||
|
PluginAbility.LYRICS -> Color(0xFFFFC107)
|
||||||
|
PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
Res.string.settings_plugins_default_ability_title,
|
||||||
|
ability.displayLabel()
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
if (selectedPlugin != null) {
|
||||||
|
Text(
|
||||||
|
selectedPlugin.name,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.settings_plugins_no_selection),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.Gray,
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AdaptiveDropdownBottomSheet(
|
||||||
|
items = menuItems,
|
||||||
|
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
|
||||||
|
header = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
|
||||||
|
color = when (ability) {
|
||||||
|
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f)
|
||||||
|
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f)
|
||||||
|
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f)
|
||||||
|
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (ability) {
|
||||||
|
PluginAbility.METADATA -> FeatherIcons.FileText
|
||||||
|
PluginAbility.AUDIO -> FeatherIcons.Music
|
||||||
|
PluginAbility.LYRICS -> FeatherIcons.AlignLeft
|
||||||
|
PluginAbility.SCROBBLE -> FeatherIcons.Activity
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
tint = when (ability) {
|
||||||
|
PluginAbility.METADATA -> Color(0xFF4CAF50)
|
||||||
|
PluginAbility.AUDIO -> Color(0xFF2196F3)
|
||||||
|
PluginAbility.LYRICS -> Color(0xFFFFC107)
|
||||||
|
PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
Res.string.settings_plugins_default_ability_title,
|
||||||
|
ability.displayLabel()
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
selectedPlugin?.let {
|
||||||
|
Text(
|
||||||
|
it.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger = { onClick ->
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.primary
|
||||||
|
),
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(8.dp))
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = FeatherIcons.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.padding(0.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (selectedPlugin != null) {
|
||||||
|
stringResource(Res.string.settings_plugins_action_change)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.settings_plugins_action_select)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun LazyListScope.pluginsSection(
|
||||||
|
pluginManager: PluginManager,
|
||||||
|
pluginState: PluginManagerStates,
|
||||||
|
navigatorCommands: NavigationCommands
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.settings_section_plugins),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = Color.Gray,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
SettingCardItem(
|
||||||
|
title = stringResource(Res.string.settings_plugins_manage_title),
|
||||||
|
subtitle = stringResource(Res.string.settings_plugins_manage_subtitle),
|
||||||
|
icon = {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
|
||||||
|
color = Color(0xFFFF9800).copy(alpha = 0.1f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = FeatherIcons.Package,
|
||||||
|
contentDescription = stringResource(Res.string.settings_section_plugins),
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
tint = Color(0xFFFF9800)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = FeatherIcons.ChevronRight,
|
||||||
|
contentDescription = stringResource(Res.string.settings_plugins_manage_title),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
navigatorCommands.navigateTo(Routes.Plugins)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(PluginAbility.entries.size) { index ->
|
||||||
|
val ability = PluginAbility.entries[index]
|
||||||
|
val selectedPlugin =
|
||||||
|
(pluginState as? PluginManagerStates.Data)?.selectedPlugins?.get(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 = {
|
||||||
|
navigatorCommands.navigateTo(Routes.Plugins)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PluginAbility.displayLabel(): String {
|
||||||
|
return when (this) {
|
||||||
|
PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata)
|
||||||
|
PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio)
|
||||||
|
PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics)
|
||||||
|
PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,12 +18,10 @@
|
|||||||
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
|
||||||
@ -37,7 +35,6 @@ 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),
|
||||||
@ -175,30 +172,3 @@ 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -33,25 +33,24 @@ internal fun LazyListScope.updatesSection(
|
|||||||
settingsViewModel: SettingsViewModel,
|
settingsViewModel: SettingsViewModel,
|
||||||
) {
|
) {
|
||||||
settingsSectionHeader(Res.string.settings_section_updates)
|
settingsSectionHeader(Res.string.settings_section_updates)
|
||||||
settingsSectionCard(
|
|
||||||
items = listOf {
|
item {
|
||||||
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)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user