diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt index a6ddc8d4..edc24ebb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt @@ -299,6 +299,26 @@ 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 fun GhostIconButton( onClick: () -> Unit, @@ -308,43 +328,13 @@ fun GhostIconButton( content: @Composable () -> Unit, ) { val baseTheme = LocalBaseUITheme.current - 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, - ) + val style = theme ?: baseTheme.iconButtons.ghost OutlineButton( onClick = onClick, modifier = modifier, enabled = enabled, hoverOnly = true, - theme = ghostStyle, + theme = style, ) { content() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt index c54f752c..2652d964 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt @@ -48,6 +48,7 @@ data class BaseUITheme( val slider: SliderTheme, val checkBox: CheckBoxTheme, val chipTab: ChipTabTheme, + val toggle: ToggleTheme, val card: CardTheme, val listRowTile: ListRowTheme, ) { @@ -177,6 +178,13 @@ data class BaseUITheme( val selected: ButtonStyle, val unselected: ButtonStyle, ) + + data class ToggleTheme( + val checked: ButtonStyle, + val unchecked: ButtonStyle, + val thumb: InteractionState, + val thumbShadow: InteractionState, + ) } val LocalBaseUITheme = staticCompositionLocalOf { @@ -519,6 +527,16 @@ fun rememberBaseUITheme(): BaseUITheme { PaddingValues(horizontal = 14.dp, vertical = 4.dp), ) + val toggleShape = RoundedCornerShape(50) + val toggleShapeState = + BaseUITheme.InteractionState(toggleShape, toggleShape, toggleShape, toggleShape) + val togglePadding = BaseUITheme.InteractionState( + PaddingValues(2.dp), + PaddingValues(2.dp), + PaddingValues(2.dp), + PaddingValues(2.dp), + ) + return BaseUITheme( buttons = BaseUITheme.ButtonVariants( primary = primaryStyle, @@ -731,6 +749,161 @@ fun rememberBaseUITheme(): BaseUITheme { 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( background = Brush.verticalGradient(listOf(containerLighter, containerDarker)), shape = RoundedCornerShape(16.dp), diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt new file mode 100644 index 00000000..446a3fe9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt @@ -0,0 +1,264 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.ui.base + +import androidx.compose.animation.core.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) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt index 6a3fb58a..b08c095c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable @@ -52,6 +51,7 @@ import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.PrimaryButton 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.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.readBytes @@ -265,7 +265,7 @@ private fun PlaylistFormFields( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Switch(checked = isPublic, onCheckedChange = onIsPublicChange) + Toggle(checked = isPublic, onCheckedChange = onIsPublicChange) } Row( @@ -285,7 +285,7 @@ private fun PlaylistFormFields( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Switch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange) + Toggle(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange) } Column( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt index 373b9cd6..633b7a48 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt @@ -32,7 +32,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton -import androidx.compose.material3.Switch +import dev.krtirtho.spotube.core.ui.base.Toggle import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField @@ -64,7 +64,7 @@ internal fun SwitchSettingCard( subtitle = subtitle, icon = icon, trailingContent = { - Switch( + Toggle( checked = checked, onCheckedChange = onCheckedChange, )