diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 81f11912..b82b5562 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -68,6 +68,14 @@ Midnight Blue Metallic Slate + Look & feel + Surface material and accent. Current: %1$s · %2$s + Look & feel + Pick a surface material, then an accent that pairs with it. Every option is a curated combination. + Surface + Accent + Secondary + Download folder override Current folder: %1$s Current folder: default music folder diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index 297f3998..648be9af 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -22,11 +22,13 @@ import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.NavDisplay @@ -157,7 +159,16 @@ fun App( } SpotubeTheme(settings = userSettings) { - val baseUITheme = rememberBaseUITheme(surfaceTheme = userSettings.surfaceTheme) + val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f + val accent = if (isDark) { + userSettings.accentColor.toDarkColor() + } else { + userSettings.accentColor.toLightColor() + } + val baseUITheme = rememberBaseUITheme( + surfaceTheme = userSettings.surfaceTheme, + accentColor = accent, + ) CompositionLocalProvider( LocalBaseUITheme provides baseUITheme, LocalBaseUIColors provides baseUITheme.colors, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt index 412c97b4..cc588b44 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt @@ -55,6 +55,18 @@ data class BaseUIColorScheme( val onPrimaryContainer: Color, val secondaryContainer: Color, val onSecondaryContainer: Color, + val backgroundPrimary: Color, + val onBackgroundPrimary: Color, + val backgroundSecondary: Color, + val onBackgroundSecondary: Color, + val accentPrimary: Color, + val onAccentPrimary: Color, + val accentSecondary: Color, + val onAccentSecondary: Color, + val backgroundInverse: Color, + val onBackgroundInverse: Color, + val actionInverse: Color, + val onActionInverse: Color, val outline: Color, val outlineVariant: Color, val error: Color, @@ -79,12 +91,52 @@ fun rememberBaseUIColorScheme( accentColor: Color? = null, ): BaseUIColorScheme { val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f - val accent = accentColor ?: MaterialTheme.colorScheme.primary + val accent = accentColor ?: surfaceTheme.accentColor(isLight) return remember(surfaceTheme, isLight, accent) { baseUIColorScheme(surfaceTheme, isLight, accent) } } +/** + * The default accent colour that characterises a surface theme. Every theme has its own, + * so a Watery app is aqua and a Chrome app is steel, unless an accent is passed explicitly. + */ +fun SurfaceStyle.accentColor(isLight: Boolean): Color = when (this) { + SurfaceStyle.Watery -> if (isLight) Color(0xFF0E8FD6) else Color(0xFF4FC3F7) + SurfaceStyle.Icy -> if (isLight) Color(0xFF3FA9E0) else Color(0xFF7FD8FF) + SurfaceStyle.Chrome -> if (isLight) Color(0xFF5B6B7C) else Color(0xFF9AA5B4) + SurfaceStyle.Acrylic -> if (isLight) Color(0xFF5C6BC0) else Color(0xFF8C9EFF) + SurfaceStyle.Glass -> if (isLight) Color(0xFF7C4DFF) else Color(0xFFB388FF) +} + +/** + * The secondary accent is derived from the primary accent as a complementary hue, so it always + * shifts with — and harmonises with — the chosen accent instead of being fixed per style. + */ +private fun Color.complementaryAccent(hueShift: Float = 180f): Color { + val (hue, saturation, value) = toHsv() + return Color.hsv( + (hue + hueShift + 360f) % 360f, + saturation.coerceIn(0.45f, 0.9f), + value.coerceIn(0.45f, 0.95f), + ) +} + +private fun Color.toHsv(): Triple { + val max = maxOf(red, green, blue) + val min = minOf(red, green, blue) + val delta = max - min + val rawHue = when { + delta == 0f -> 0f + max == red -> 60f * (((green - blue) / delta) % 6f) + max == green -> 60f * (((blue - red) / delta) + 2f) + else -> 60f * (((red - green) / delta) + 4f) + } + val hue = if (rawHue < 0f) rawHue + 360f else rawHue + val saturation = if (max == 0f) 0f else delta / max + return Triple(hue, saturation, max) +} + fun baseUIColorScheme( surfaceTheme: SurfaceStyle, isLight: Boolean, @@ -99,6 +151,21 @@ fun baseUIColorScheme( lerp(accent, Color.White, 0.7f) } val secondaryContainer = lerp(palette.surface, accent, if (isLight) 0.22f else 0.34f) + val inversePalette = surfacePalette(surfaceTheme, !isLight) + val secondaryAccent = accent.complementaryAccent() + val onSecondaryAccent = if (secondaryAccent.luminance() > 0.5f) Color(0xFF10161C) else Color.White + val secondaryBackground = if (isLight) { + lerp(palette.surface, secondaryAccent, 0.14f) + } else { + lerp(palette.surface, secondaryAccent, 0.22f) + } + val onSecondaryBackground = if (secondaryBackground.luminance() > 0.5f) { + lerp(secondaryAccent, Color.Black, 0.6f) + } else { + lerp(secondaryAccent, Color.White, 0.82f) + } + val actionInverse = if (isLight) lerp(accent, Color.Black, 0.45f) else lerp(accent, Color.White, 0.5f) + val onActionInverse = if (actionInverse.luminance() > 0.5f) Color(0xFF10161C) else Color.White return BaseUIColorScheme( surfaceTheme = surfaceTheme, @@ -120,6 +187,18 @@ fun baseUIColorScheme( onPrimaryContainer = onPrimaryContainer, secondaryContainer = secondaryContainer, onSecondaryContainer = palette.onSurface, + backgroundPrimary = palette.surface, + onBackgroundPrimary = palette.onSurface, + backgroundSecondary = secondaryBackground, + onBackgroundSecondary = onSecondaryBackground, + accentPrimary = accent, + onAccentPrimary = onPrimary, + accentSecondary = secondaryAccent, + onAccentSecondary = onSecondaryAccent, + backgroundInverse = inversePalette.surface, + onBackgroundInverse = inversePalette.onSurface, + actionInverse = actionInverse, + onActionInverse = onActionInverse, outline = palette.outline, outlineVariant = palette.outlineVariant, error = if (isLight) Color(0xFFC0392B) else Color(0xFFE57373), @@ -128,7 +207,7 @@ fun baseUIColorScheme( ) } -private class SurfacePalette( +internal class SurfacePalette( val backgroundTop: Color, val backgroundBottom: Color, val surface: Color, @@ -143,7 +222,7 @@ private class SurfacePalette( val outlineVariant: Color, ) -private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette { +internal fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette { return if (isLight) { when (style) { SurfaceStyle.Glass -> SurfacePalette( @@ -177,18 +256,18 @@ private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalett ) SurfaceStyle.Watery -> SurfacePalette( - backgroundTop = Color(0xFFD6F0FF), - backgroundBottom = Color(0xFFB4DFFA), + backgroundTop = Color(0xFFEAF7FE), + backgroundBottom = Color(0xFFD8EFFB), surface = Color(0xFFF0FAFF), - surfaceVariant = Color(0xFFDCF0FC), - surfaceContainer = Color(0xFFE7F5FE), - surfaceContainerHigh = Color(0xFFEFF9FF), - surfaceContainerHighest = Color(0xFFCFE9F8), + surfaceVariant = Color(0xFFE2F2FB), + surfaceContainer = Color(0xFFEAF6FD), + surfaceContainerHigh = Color(0xFFF2FAFE), + surfaceContainerHighest = Color(0xFFD8ECF8), onBackground = Color(0xFF08324A), onSurface = Color(0xFF08324A), onSurfaceVariant = Color(0xFF3A6884), - outline = Color(0xFF8FBBD4), - outlineVariant = Color(0xFFC6E2F2), + outline = Color(0xFF9EC4DA), + outlineVariant = Color(0xFFD0E7F4), ) SurfaceStyle.Icy -> SurfacePalette( @@ -254,8 +333,8 @@ private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalett ) SurfaceStyle.Watery -> SurfacePalette( - backgroundTop = Color(0xFF06121E), - backgroundBottom = Color(0xFF0B1F31), + backgroundTop = Color(0xFF0A1622), + backgroundBottom = Color(0xFF0E1E2D), surface = Color(0xFF0E2233), surfaceVariant = Color(0xFF153148), surfaceContainer = Color(0xFF0B1D2C), 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 430b2899..daccca41 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 @@ -138,6 +138,27 @@ private fun Modifier.applyShadow( } } +/** + * Overlays the ambient [SurfaceStyle] material for a colour's [BaseUITheme.ButtonColors.surfaceRole], + * so an actionable item looks exactly like the matching [BaseSurface] role. + */ +@Composable +internal fun Modifier.applySurfaceMaterial(colors: BaseUITheme.ButtonColors): Modifier = + applySurfaceMaterial(colors.surfaceRole) + +@Composable +internal fun Modifier.applySurfaceMaterial(role: SurfaceRole?): Modifier { + val scheme = LocalBaseUIColors.current + return role?.let { baseSurfaceTexture(scheme.surfaceTheme, it, scheme.isLight) } ?: this +} + +/** + * The generic button gloss. Textured items get their sheen from the surface material, so it + * is only applied to colours without a [BaseUITheme.ButtonColors.surfaceRole]. + */ +internal fun Modifier.applyHighlight(colors: BaseUITheme.ButtonColors): Modifier = + if (colors.surfaceRole == null) highlight(colors.highlight) else this + @Composable fun OutlineButton( onClick: () -> Unit, @@ -172,6 +193,7 @@ fun OutlineButton( state.colors.background, state.shape ) + .applySurfaceMaterial(state.colors) .border(BorderStroke(state.border.width, state.border.color), state.shape) ) .clickable( @@ -180,7 +202,7 @@ fun OutlineButton( indication = ripple(), onClick = onClick, ) - .then(if (hoverOnly && !isHovered) Modifier else Modifier.highlight(state.colors.highlight)) + .then(if (hoverOnly && !isHovered) Modifier else Modifier.applyHighlight(state.colors)) .padding(state.padding), contentAlignment = Alignment.Center, ) { @@ -221,6 +243,7 @@ fun PrimaryButton( .applyShadow(state.shadow, state.shape) .clip(state.shape) .background(state.colors.background, state.shape) + .applySurfaceMaterial(state.colors) .border(BorderStroke(state.border.width, state.border.color), state.shape) .clickable( enabled = enabled, @@ -228,7 +251,7 @@ fun PrimaryButton( indication = ripple(), onClick = onClick, ) - .highlight(state.colors.highlight) + .applyHighlight(state.colors) .padding(state.padding), contentAlignment = Alignment.Center, ) { @@ -267,6 +290,7 @@ fun SecondaryButton( .applyShadow(state.shadow, state.shape) .clip(state.shape) .background(state.colors.background, state.shape) + .applySurfaceMaterial(state.colors) .border(BorderStroke(state.border.width, state.border.color), state.shape) .clickable( enabled = enabled, @@ -274,7 +298,7 @@ fun SecondaryButton( indication = ripple(), onClick = onClick, ) - .highlight(state.colors.highlight) + .applyHighlight(state.colors) .padding(state.padding), contentAlignment = Alignment.Center, ) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt index cdb7f54e..91802df8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt @@ -75,6 +75,7 @@ fun Card( ) .clip(cardTheme.shape) .background(cardTheme.background, cardTheme.shape) + .applySurfaceMaterial(cardTheme.surfaceRole) .border(BorderStroke(cardTheme.border.width, cardTheme.border.color), cardTheme.shape) .padding(cardTheme.padding), ) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt index af1f0643..b037e565 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt @@ -146,8 +146,9 @@ fun CheckBox( ) .clip(resolved.shape) .background(resolved.colors.background, resolved.shape) + .applySurfaceMaterial(resolved.colors) .border(BorderStroke(0.5.dp, borderColor), resolved.shape) - .highlight(resolved.colors.highlight) + .applyHighlight(resolved.colors) .then( if (onClick != null) { Modifier diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt index e8042ee0..98a34762 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt @@ -123,6 +123,7 @@ fun ChipTab( .applyChipShadow(state.shadow, state.shape) .clip(state.shape) .background(state.colors.background, state.shape) + .applySurfaceMaterial(state.colors) .border(BorderStroke(state.border.width, state.border.color), state.shape) .clickable( enabled = enabled, @@ -130,7 +131,7 @@ fun ChipTab( indication = ripple(), onClick = onClick, ) - .highlight(state.colors.highlight) + .applyHighlight(state.colors) .padding(state.padding), contentAlignment = Alignment.Center, ) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt index 59a6ea61..70ffe90f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt @@ -39,12 +39,14 @@ import androidx.compose.ui.unit.dp /** * A theme-aware replacement for [Scaffold]. * - * Material's [Scaffold] paints Material's own surface colour, which hides the selected - * [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass). [BaseScaffold] instead: + * A scaffold is always the current theme's **Primary Background** — there is no option to + * switch its surface. Material's [Scaffold] would paint Material's own surface colour and + * hide the selected [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass), so [BaseScaffold] + * paints the theme's primary background material instead. * - * - is **transparent by default**, so the app-wide themed backdrop shows through, or - * - paints a chosen [surface] material locally, or - * - uses an explicit [containerColor] when one is given. + * [containerColor] is a narrow escape hatch for the rare screen that paints its own + * backdrop (e.g. onboarding) and needs the scaffold to stay out of the way; it is not a + * surface-style switch. * * It keeps the same slot structure as [Scaffold] (top/bottom bars, snackbar host, * floating action button) and hands the resolved [PaddingValues] to [content]. @@ -56,7 +58,6 @@ fun BaseScaffold( bottomBar: @Composable () -> Unit = {}, snackbarHost: @Composable () -> Unit = {}, floatingActionButton: @Composable () -> Unit = {}, - surface: SurfaceStyle? = null, containerColor: Color? = null, contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, content: @Composable (PaddingValues) -> Unit, @@ -70,15 +71,9 @@ fun BaseScaffold( containerColor = Color.Transparent, contentWindowInsets = contentWindowInsets, ) { innerPadding -> - val backgroundModifier = when { - containerColor != null -> Modifier.background(containerColor) - surface != null -> { - val isLight = LocalBaseUIColors.current.isLight - Modifier.baseSurfaceBackdrop(surface, isLight) - } - - else -> Modifier - } + val colors = LocalBaseUIColors.current + val backgroundModifier = containerColor?.let { Modifier.background(it) } + ?: Modifier.baseSurfaceBackdrop(colors.surfaceTheme, colors.isLight) Box(modifier = Modifier.fillMaxSize().then(backgroundModifier)) { content(innerPadding) } @@ -90,9 +85,7 @@ fun BaseScaffold( @Composable private fun BaseScaffoldPreview() { BaseUIPreview { - val style = LocalBaseUITheme.current.surfaceTheme BaseScaffold( - surface = style, topBar = { Text( text = "Title", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt index f10ae714..d7c7b366 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -48,7 +49,8 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -76,7 +78,31 @@ enum class SurfaceStyle { } /** - * A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials. + * The role a surface plays within a theme. Each style produces a distinct material for + * every role, so a theme is composed from them: + * + * - [PrimaryBackground] — the subtle main background (pages, panels). + * - [SecondaryBackground] — a distinct, complementary background hue that pairs with the + * primary (grouped/alternate sections). + * - [PrimaryAccent] — the primary accent surface for interactive elements (buttons, + * toggles, active chips). + * - [SecondaryAccent] — a complementary accent surface (secondary actions, highlights). + * - [InverseBackground] — the inverted-brightness background (tooltips, snackbars). + * - [InverseAction] — the inverted-brightness action surface. + */ +@Serializable +enum class SurfaceRole { + PrimaryBackground, + SecondaryBackground, + PrimaryAccent, + SecondaryAccent, + InverseBackground, + InverseAction, +} + +/** + * A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials, + * in the given [SurfaceRole]. * * Distinct from [androidx.compose.material3.Surface] — this draws decorative material * backgrounds (gradients, gloss, grain, bubbles, facets) and provides a matching @@ -85,7 +111,8 @@ enum class SurfaceStyle { @Composable fun BaseSurface( modifier: Modifier = Modifier, - style: SurfaceStyle = SurfaceStyle.Glass, + style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme, + role: SurfaceRole = SurfaceRole.PrimaryBackground, shape: Shape = RectangleShape, elevation: Dp? = null, contentColor: Color? = null, @@ -93,63 +120,60 @@ fun BaseSurface( contentAlignment: Alignment = Alignment.TopStart, content: @Composable BoxScope.() -> Unit, ) { - val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f - val onSurface = MaterialTheme.colorScheme.onSurface + val ambientScheme = LocalBaseUIColors.current + val isLight = ambientScheme.isLight + // A surface is rendered in its own style's colour scheme, so previewing a style shows + // that style's real accent instead of the ambient theme's. When the requested style is + // the ambient theme, the ambient scheme is used so a user-chosen accent still applies. + val ownScheme = remember(style, isLight) { + baseUIColorScheme(style, isLight, style.accentColor(isLight)) + } + val scheme = if (ambientScheme.surfaceTheme == style) ambientScheme else ownScheme + val palette = surfacePalette(style, isLight) - val resolvedElevation = elevation ?: when (style) { + val baseElevation = when (style) { SurfaceStyle.Watery -> 14.dp SurfaceStyle.Icy -> 12.dp SurfaceStyle.Chrome -> 10.dp SurfaceStyle.Acrylic -> 16.dp SurfaceStyle.Glass -> 12.dp } - val resolvedContentColor = contentColor ?: when (style) { - SurfaceStyle.Watery -> Color.White - SurfaceStyle.Icy -> if (isLight) Color(0xFF0B3A55) else Color(0xFFDCEBF7) - SurfaceStyle.Chrome -> if (isLight) Color(0xFF1B2733) else Color(0xFFE7ECF2) - SurfaceStyle.Acrylic, SurfaceStyle.Glass -> onSurface + val resolvedElevation = elevation ?: when (role) { + SurfaceRole.PrimaryBackground -> baseElevation + SurfaceRole.SecondaryBackground -> baseElevation + 2.dp + SurfaceRole.PrimaryAccent, SurfaceRole.SecondaryAccent -> baseElevation + 6.dp + SurfaceRole.InverseBackground -> baseElevation + SurfaceRole.InverseAction -> baseElevation + 6.dp } - val ambient = when (style) { - SurfaceStyle.Watery -> Color(0xFF0E6FB0).copy(alpha = 0.38f) - SurfaceStyle.Icy -> Color(0xFF3FA9E0).copy(alpha = 0.3f) - SurfaceStyle.Chrome -> Color.Black.copy(alpha = 0.3f) - SurfaceStyle.Acrylic -> onSurface.copy(alpha = 0.22f) - SurfaceStyle.Glass -> onSurface.copy(alpha = 0.18f) + val resolvedContentColor = contentColor ?: when (role) { + SurfaceRole.PrimaryBackground -> scheme.onBackgroundPrimary + SurfaceRole.SecondaryBackground -> scheme.onBackgroundSecondary + SurfaceRole.PrimaryAccent -> scheme.onAccentPrimary + SurfaceRole.SecondaryAccent -> scheme.onAccentSecondary + SurfaceRole.InverseBackground -> scheme.onBackgroundInverse + SurfaceRole.InverseAction -> scheme.onActionInverse } - val spot = ambient.copy(alpha = (ambient.alpha + 0.08f).coerceAtMost(1f)) - - val borderBrush = when (style) { - SurfaceStyle.Watery -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)), - ) - - SurfaceStyle.Icy -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.95f), Color.White.copy(alpha = 0.35f)), - ) - - SurfaceStyle.Chrome -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.95f), Color.Black.copy(alpha = 0.28f)), - ) - - SurfaceStyle.Acrylic -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.12f)), - ) - - SurfaceStyle.Glass -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.9f), Color.White.copy(alpha = 0.3f)), - ) + val ambient = when (role) { + SurfaceRole.PrimaryAccent -> scheme.accentPrimary.copy(alpha = 0.42f) + SurfaceRole.SecondaryAccent -> scheme.accentSecondary.copy(alpha = 0.42f) + SurfaceRole.InverseAction -> scheme.actionInverse.copy(alpha = 0.42f) + SurfaceRole.InverseBackground -> Color.Black.copy(alpha = 0.3f) + else -> palette.onSurface.copy(alpha = 0.2f) } + val spot = ambient.copy(alpha = (ambient.alpha + 0.07f).coerceAtMost(1f)) + val borderBrush = roleBorderBrush(style, role, isLight, scheme) // Translucent materials (Glass, Acrylic) must not use a drop shadow: it is drawn // behind the node and, because the panel is see-through, shows up as a dark inset - // shape. Their depth comes from the edge, sheen and bevel instead. - val castsShadow = style != SurfaceStyle.Glass && style != SurfaceStyle.Acrylic + // shape. Accent and secondary/inverse roles are filled, so they can cast one. + val translucent = role == SurfaceRole.PrimaryBackground && + (style == SurfaceStyle.Glass || style == SurfaceStyle.Acrylic) CompositionLocalProvider(LocalContentColor provides resolvedContentColor) { Box( modifier = modifier .then( - if (castsShadow) { + if (!translucent) { Modifier.shadow( elevation = resolvedElevation, shape = shape, @@ -162,7 +186,7 @@ fun BaseSurface( ) .clip(shape) .drawWithCache { - onDrawBehind { drawSurfaceMaterial(style, isLight) } + onDrawBehind { drawSurfaceMaterial(style, role, isLight, scheme) } } .border(BorderStroke(1.dp, borderBrush), shape) .padding(contentPadding), @@ -173,199 +197,313 @@ fun BaseSurface( } /** - * Paints the full-bleed material of [style] as a page background: the material body plus - * its own sheen and reflections, but no panel bevel (a backdrop has no edges). + * Paints the full-bleed material of [style] as a page background: the primary material + * body plus its sheen and reflections, but no panel bevel (a backdrop has no edges). */ fun Modifier.baseSurfaceBackdrop(style: SurfaceStyle, isLight: Boolean): Modifier = drawWithCache { - onDrawBehind { drawSurfaceBase(style, isLight) } - } - -private fun DrawScope.drawSurfaceMaterial(style: SurfaceStyle, isLight: Boolean) { - drawSurfaceBase(style, isLight) - drawInnerBevel(isLight) -} - -private fun DrawScope.drawSurfaceBase(style: SurfaceStyle, isLight: Boolean) { - when (style) { - SurfaceStyle.Watery -> drawWatery(isLight) - SurfaceStyle.Icy -> drawIcy(isLight) - SurfaceStyle.Chrome -> drawChrome(isLight) - SurfaceStyle.Acrylic -> drawAcrylic(isLight) - SurfaceStyle.Glass -> drawGlass(isLight) - } -} - -private fun DrawScope.drawGlass(isLight: Boolean) { - // See-through glass: a faint blue-tinted fill so whatever is behind shows through, - // defined by its bright edge, sheen and shadow rather than an opaque body. - val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6) - drawRect( - brush = Brush.verticalGradient( - listOf( - tint.copy(alpha = if (isLight) 0.4f else 0.2f), - tint.copy(alpha = if (isLight) 0.24f else 0.1f), - ), - ), - ) - drawGloss(if (isLight) 0.55f else 0.16f) - drawBottomReflection(if (isLight) 0.4f else 0.1f) - drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f) -} - -private fun DrawScope.drawAcrylic(isLight: Boolean) { - // See-through acrylic: a translucent tinted sheet with a fine grain. - val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8) - drawRect( - brush = Brush.verticalGradient( - listOf( - tint.copy(alpha = if (isLight) 0.45f else 0.22f), - tint.copy(alpha = if (isLight) 0.28f else 0.12f), - ), - ), - ) - - // Fine grain so the panel reads as a translucent acrylic sheet. - val random = Random(23) - repeat(180) { - val light = random.nextBoolean() - drawCircle( - color = if (light) { - Color.White.copy(alpha = if (isLight) 0.16f else 0.06f) - } else { - Color.Black.copy(alpha = if (isLight) 0.06f else 0.08f) - }, - radius = 0.7f, - center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), - ) - } - drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f) -} - -private fun DrawScope.drawChrome(isLight: Boolean) { - if (isLight) { - drawRect( - brush = Brush.verticalGradient( - 0f to Color(0xFFF8FBFF), - 0.16f to Color(0xFFD6DEE7), - 0.4f to Color(0xFF8E99A8), - 0.5f to Color(0xFFEDF2F7), - 0.62f to Color(0xFF9AA5B4), - 1f to Color(0xFF6C7787), - ), - ) - // Hard specular reflection near the top. - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.White.copy(alpha = 0.85f), Color.Transparent), - startY = size.height * 0.05f, - endY = size.height * 0.22f, - ), - ) - } else { - // Dark gunmetal for the night variant. - drawRect( - brush = Brush.verticalGradient( - 0f to Color(0xFF3B424C), - 0.16f to Color(0xFF2A303A), - 0.4f to Color(0xFF1B2028), - 0.5f to Color(0xFF343B45), - 0.62f to Color(0xFF232830), - 1f to Color(0xFF141820), - ), - ) - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.White.copy(alpha = 0.18f), Color.Transparent), - startY = size.height * 0.05f, - endY = size.height * 0.22f, - ), - ) - } - // Darkened lower edge for the metal bevel. - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.25f)), - startY = size.height * 0.78f, - endY = size.height, - ), - ) -} - -private fun DrawScope.drawIcy(isLight: Boolean) { - val colors = if (isLight) { - listOf(Color(0xFFF4FCFF), Color(0xFFCBE9FB), Color(0xFF9BD3F0)) - } else { - // Snowy-night ice: dim slate-blue with faint frost. - listOf(Color(0xFF2A3B4D), Color(0xFF1B2A39), Color(0xFF0F1A26)) - } - drawRect(brush = Brush.verticalGradient(colors)) - - val frost = if (isLight) Color.White else Color(0xFFB8CCE0) - - // Crystalline facets (kept inset so they are never clipped by the shape). - val random = Random(37) - repeat(4) { - val s = size.minDimension * (0.2f + random.nextFloat() * 0.3f) - val x = random.nextFloat() * (size.width - s).coerceAtLeast(0f) - val y = random.nextFloat() * (size.height - s).coerceAtLeast(0f) - val path = Path().apply { - moveTo(x, y) - lineTo(x + s, y + s * 0.4f) - lineTo(x + s * 0.4f, y + s) - close() + onDrawBehind { + drawStyleBase(style, isLight) + drawStyleDecorations(style, isLight) } - drawPath(path, frost.copy(alpha = if (isLight) 0.25f else 0.12f)) } - // Frost speckles. - repeat(90) { - drawCircle( - color = frost.copy(alpha = if (isLight) 0.5f else 0.28f), - radius = 0.8f, - center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), - ) +/** + * Overlays a [SurfaceStyle]'s material character (gloss, bubbles, facets, specular band, + * grain) and its inner bevel on a node that already carries a base fill. Compact components + * — buttons, toggles, chips — use this to share the exact look of a [BaseSurface] in [role] + * without redrawing the fill (which comes from their own interaction-state brush). + */ +fun Modifier.baseSurfaceTexture( + style: SurfaceStyle, + role: SurfaceRole, + isLight: Boolean, +): Modifier = drawWithCache { + onDrawBehind { + val effectiveLight = when (role) { + SurfaceRole.InverseBackground, SurfaceRole.InverseAction -> !isLight + else -> isLight + } + drawStyleDecorations(style, effectiveLight) + drawInnerBevel(effectiveLight) } - drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f) } -private fun DrawScope.drawWatery(isLight: Boolean) { - val colors = if (isLight) { - listOf(Color(0xFFA6E3FF), Color(0xFF3FB6EA), Color(0xFF0E6FB0)) - } else { - // Moonlit night water: deep blue with a pale silver sheen. - listOf(Color(0xFF1C3A5E), Color(0xFF102942), Color(0xFF071827)) +/** Ambient-scheme overload of [baseSurfaceTexture] for a single role. */ +@Composable +fun Modifier.baseSurfaceTexture(role: SurfaceRole): Modifier { + val scheme = LocalBaseUIColors.current + return baseSurfaceTexture(scheme.surfaceTheme, role, scheme.isLight) +} + +/** + * Paints the full [SurfaceStyle] material for [role] — base fill, material character and inner + * bevel — using the ambient [BaseUIColorScheme]. Use it to make a chrome panel (a sidebar, a + * player bar) a first-class surface rather than a flat Material surface. + */ +@Composable +fun Modifier.baseSurfaceMaterial( + role: SurfaceRole = SurfaceRole.PrimaryBackground, + style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme, +): Modifier { + val scheme = LocalBaseUIColors.current + return drawWithCache { + onDrawBehind { drawSurfaceMaterial(style, role, scheme.isLight, scheme) } } - drawRect(brush = Brush.verticalGradient(colors)) +} - val sheen = if (isLight) Color.White else Color(0xFFB9D2F0) +private fun DrawScope.drawSurfaceMaterial( + style: SurfaceStyle, + role: SurfaceRole, + isLight: Boolean, + scheme: BaseUIColorScheme, +) { + // Role base fill. + when (role) { + SurfaceRole.PrimaryBackground -> drawStyleBase(style, isLight) - // Caustic / moonlight pooling from the surface. + // A distinct, complementary background hue (keeps the style's texture below). + SurfaceRole.SecondaryBackground -> drawRect(color = scheme.backgroundSecondary) + + SurfaceRole.PrimaryAccent -> drawAccentBase(scheme.accentPrimary) + SurfaceRole.SecondaryAccent -> drawAccentBase(scheme.accentSecondary) + + // Opaque inverted background so translucent styles still read as a solid surface. + SurfaceRole.InverseBackground -> drawRect(color = scheme.backgroundInverse) + + SurfaceRole.InverseAction -> drawAccentBase(scheme.actionInverse) + } + + // The material's own character (bubbles, facets, grain, specular band, sheen). + val effectiveLight = when (role) { + SurfaceRole.InverseBackground, SurfaceRole.InverseAction -> !isLight + else -> isLight + } + drawStyleDecorations(style, effectiveLight) + drawInnerBevel(effectiveLight) +} + +// --- Material base gradients (one per style) --------------------------------- + +private fun DrawScope.drawStyleBase(style: SurfaceStyle, isLight: Boolean) { + when (style) { + SurfaceStyle.Glass -> { + val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6) + drawRect( + brush = Brush.verticalGradient( + listOf( + tint.copy(alpha = if (isLight) 0.4f else 0.2f), + tint.copy(alpha = if (isLight) 0.24f else 0.1f), + ), + ), + ) + } + + SurfaceStyle.Acrylic -> { + val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8) + drawRect( + brush = Brush.verticalGradient( + listOf( + tint.copy(alpha = if (isLight) 0.45f else 0.22f), + tint.copy(alpha = if (isLight) 0.28f else 0.12f), + ), + ), + ) + } + + SurfaceStyle.Chrome -> { + if (isLight) { + drawRect( + brush = Brush.verticalGradient( + 0f to Color(0xFFF8FBFF), + 0.16f to Color(0xFFD6DEE7), + 0.4f to Color(0xFF8E99A8), + 0.5f to Color(0xFFEDF2F7), + 0.62f to Color(0xFF9AA5B4), + 1f to Color(0xFF6C7787), + ), + ) + } else { + drawRect( + brush = Brush.verticalGradient( + 0f to Color(0xFF3B424C), + 0.16f to Color(0xFF2A303A), + 0.4f to Color(0xFF1B2028), + 0.5f to Color(0xFF343B45), + 0.62f to Color(0xFF232830), + 1f to Color(0xFF141820), + ), + ) + } + } + + SurfaceStyle.Icy -> { + val colors = if (isLight) { + listOf(Color(0xFFF4FCFF), Color(0xFFCBE9FB), Color(0xFF9BD3F0)) + } else { + listOf(Color(0xFF2A3B4D), Color(0xFF1B2A39), Color(0xFF0F1A26)) + } + drawRect(brush = Brush.verticalGradient(colors)) + } + + SurfaceStyle.Watery -> { + // A clear-but-soft aqua column: unmistakably water, without a heavy flat blue. + val colors = if (isLight) { + listOf(Color(0xFFD8F0FD), Color(0xFFB0DEF7), Color(0xFF83C8EE)) + } else { + listOf(Color(0xFF123249), Color(0xFF0B2135), Color(0xFF06141F)) + } + drawRect(brush = Brush.verticalGradient(colors)) + } + } +} + +/** The accent-filled base used by [SurfaceRole.Action]. */ +private fun DrawScope.drawAccentBase(accent: Color) { drawRect( brush = Brush.verticalGradient( - colors = listOf(sheen.copy(alpha = if (isLight) 0.35f else 0.22f), Color.Transparent), - startY = 0f, - endY = size.height * 0.5f, + listOf( + lerp(accent, Color.White, 0.45f), + accent, + lerp(accent, Color.Black, 0.16f), + ), ), ) +} - val random = Random(53) - repeat(5) { - val rx = size.width * (0.12f + random.nextFloat() * 0.2f) - val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f) - val cy = rx * 0.5f + random.nextFloat() * (size.height * 0.6f - rx).coerceAtLeast(0f) - drawOval( - brush = Brush.radialGradient( - colors = listOf(sheen.copy(alpha = if (isLight) 0.3f else 0.18f), Color.Transparent), - center = Offset(cx, cy), - radius = rx, - ), - topLeft = Offset(cx - rx, cy - rx * 0.5f), - size = Size(rx * 2f, rx), - ) +// --- Material decorations (one per style) ------------------------------------ + +private fun DrawScope.drawStyleDecorations(style: SurfaceStyle, isLight: Boolean) { + when (style) { + SurfaceStyle.Glass -> { + drawGloss(if (isLight) 0.55f else 0.16f) + drawBottomReflection(if (isLight) 0.4f else 0.1f) + drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f) + } + + SurfaceStyle.Acrylic -> { + // Fine grain so the panel reads as a translucent acrylic sheet. + val random = Random(23) + repeat(180) { + val light = random.nextBoolean() + drawCircle( + color = if (light) { + Color.White.copy(alpha = if (isLight) 0.16f else 0.06f) + } else { + Color.Black.copy(alpha = if (isLight) 0.06f else 0.08f) + }, + radius = 0.7f, + center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), + ) + } + drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f) + } + + SurfaceStyle.Chrome -> { + // Hard specular reflection near the top. + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = if (isLight) 0.85f else 0.18f), + Color.Transparent, + ), + startY = size.height * 0.05f, + endY = size.height * 0.22f, + ), + ) + // Darkened lower edge for the metal bevel. + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.25f)), + startY = size.height * 0.78f, + endY = size.height, + ), + ) + } + + SurfaceStyle.Icy -> { + val frost = if (isLight) Color.White else Color(0xFFB8CCE0) + + // Crystalline facets (kept inset so they are never clipped by the shape). + val random = Random(37) + repeat(4) { + val s = size.minDimension * (0.2f + random.nextFloat() * 0.3f) + val x = random.nextFloat() * (size.width - s).coerceAtLeast(0f) + val y = random.nextFloat() * (size.height - s).coerceAtLeast(0f) + val path = Path().apply { + moveTo(x, y) + lineTo(x + s, y + s * 0.4f) + lineTo(x + s * 0.4f, y + s) + close() + } + drawPath(path, frost.copy(alpha = if (isLight) 0.25f else 0.12f)) + } + + // Frost speckles. + repeat(90) { + drawCircle( + color = frost.copy(alpha = if (isLight) 0.5f else 0.28f), + radius = 0.8f, + center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), + ) + } + drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f) + } + + SurfaceStyle.Watery -> { + val sheen = if (isLight) Color.White else Color(0xFFAFD3F2) + + // Caustic light pooling from the surface. + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + sheen.copy(alpha = if (isLight) 0.6f else 0.2f), + Color.Transparent, + ), + startY = 0f, + endY = size.height * 0.55f, + ), + ) + + val random = Random(53) + // Refracted caustic blobs — the bright, rippling patterns of moving water. + repeat(9) { + val rx = size.minDimension * (0.14f + random.nextFloat() * 0.26f) + val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f) + val cy = rx + random.nextFloat() * (size.height - 2 * rx).coerceAtLeast(0f) + drawOval( + brush = Brush.radialGradient( + colors = listOf( + sheen.copy(alpha = if (isLight) 0.5f else 0.22f), + Color.Transparent, + ), + center = Offset(cx, cy), + radius = rx, + ), + topLeft = Offset(cx - rx, cy - rx * 0.7f), + size = Size(rx * 2f, rx * 1.4f), + ) + } + + // Ripple crests: thin horizontal highlights that read as surface waves. + repeat(3) { + val y = size.height * (0.18f + random.nextFloat() * 0.64f) + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + sheen.copy(alpha = if (isLight) 0.4f else 0.16f), + Color.Transparent, + ), + startY = y - size.height * 0.035f, + endY = y + size.height * 0.035f, + ), + ) + } + + drawBubbles(seed = 71, count = 11, alpha = if (isLight) 0.6f else 0.3f, rim = true) + } } - - drawBubbles(seed = 71, count = 6, alpha = if (isLight) 0.4f else 0.22f) } private fun DrawScope.drawGloss(alpha: Float, heightFraction: Float = 0.5f) { @@ -406,7 +544,7 @@ private fun DrawScope.drawInnerBevel(isLight: Boolean) { ) } -private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float) { +private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float, rim: Boolean = false) { val random = Random(seed) repeat(count) { val radius = size.minDimension * (0.05f + random.nextFloat() * 0.14f) @@ -423,59 +561,97 @@ private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float) { radius = radius, center = center, ) - } -} - -@BaseUIPhonePreview -@BaseUIDesktopPreview -@Composable -private fun BaseSurfacePreview() { - BaseUIPreview { - Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - SurfaceStyle.entries.forEach { style -> - BaseSurface( - style = style, - modifier = Modifier - .fillMaxWidth() - .height(84.dp), - ) { - Text( - text = style.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - } - } + if (rim) { + drawCircle( + color = Color.White.copy(alpha = alpha * 0.7f), + radius = radius, + center = center, + style = Stroke(width = (radius * 0.14f).coerceAtLeast(0.8f)), + ) } } } -@BaseUIPhonePreview +private fun styleBorderBrush(style: SurfaceStyle): Brush = when (style) { + SurfaceStyle.Watery -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)), + ) + + SurfaceStyle.Icy -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.95f), Color.White.copy(alpha = 0.35f)), + ) + + SurfaceStyle.Chrome -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.95f), Color.Black.copy(alpha = 0.28f)), + ) + + SurfaceStyle.Acrylic -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.12f)), + ) + + SurfaceStyle.Glass -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.9f), Color.White.copy(alpha = 0.3f)), + ) +} + +private fun roleBorderBrush( + style: SurfaceStyle, + role: SurfaceRole, + isLight: Boolean, + scheme: BaseUIColorScheme, +): Brush = when (role) { + SurfaceRole.PrimaryBackground -> styleBorderBrush(style) + + SurfaceRole.SecondaryBackground -> Brush.verticalGradient( + listOf( + lerp(scheme.backgroundSecondary, Color.White, 0.5f) + .copy(alpha = if (isLight) 0.85f else 0.6f), + Color.White.copy(alpha = 0.12f), + ), + ) + + SurfaceRole.PrimaryAccent -> accentEdgeBrush(scheme.accentPrimary) + SurfaceRole.SecondaryAccent -> accentEdgeBrush(scheme.accentSecondary) + + SurfaceRole.InverseBackground -> Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.1f)), + ) + + SurfaceRole.InverseAction -> accentEdgeBrush(scheme.actionInverse) +} + +private fun accentEdgeBrush(accent: Color): Brush = Brush.verticalGradient( + listOf(lerp(accent, Color.White, 0.6f), lerp(accent, Color.Black, 0.2f)), +) + +/** + * One preview showing everything about surfaces: every [SurfaceStyle] crossed with every + * [SurfaceRole], plus the full-bleed backdrops — no need to scroll through many previews. + */ +@BaseUIDesktopPreview @Composable -private fun BaseSurfaceGridPreview() { +private fun SurfacePreview() { BaseUIPreview { + val colors = LocalBaseUIColors.current Column( modifier = Modifier .fillMaxSize() .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - SurfaceStyle.entries.chunked(2).forEach { rowStyles -> + SurfaceStyle.entries.forEach { style -> Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - rowStyles.forEach { style -> + SurfaceRole.entries.forEach { role -> BaseSurface( style = style, + role = role, modifier = Modifier .weight(1f) - .height(140.dp), + .height(84.dp), + contentPadding = PaddingValues(10.dp), ) { Column( modifier = Modifier.fillMaxSize(), @@ -483,54 +659,41 @@ private fun BaseSurfaceGridPreview() { ) { Text( text = style.name, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, ) Text( - text = "Frutiger Aero", - style = MaterialTheme.typography.labelMedium, + text = role.name, + style = MaterialTheme.typography.labelSmall, ) } } } - repeat(2 - rowStyles.size) { - androidx.compose.foundation.layout.Spacer(modifier = Modifier.weight(1f)) + } + } + + // Full-bleed backdrops (the page background material, without the panel bevel). + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + SurfaceStyle.entries.forEach { style -> + Box( + modifier = Modifier + .weight(1f) + .height(60.dp) + .clip(RoundedCornerShape(12.dp)) + .baseSurfaceBackdrop(style, colors.isLight), + contentAlignment = Alignment.Center, + ) { + Text( + text = style.name, + style = MaterialTheme.typography.labelSmall, + color = colors.onSurface, + ) } } } } } } - -@BaseUIPhonePreview -@BaseUIDesktopPreview -@Composable -private fun SurfaceThemeBackdropPreview() { - val isLight = LocalBaseUIColors.current.isLight - val onSurface = LocalBaseUIColors.current.onSurface - Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - SurfaceStyle.entries.forEach { style -> - Box( - modifier = Modifier - .fillMaxWidth() - .height(66.dp) - .clip(RoundedCornerShape(14.dp)) - .baseSurfaceBackdrop(style, isLight), - contentAlignment = Alignment.CenterStart, - ) { - Text( - text = style.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = if (style == SurfaceStyle.Watery) Color.White else onSurface, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - } - } -} 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 826a3319..33880cab 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 @@ -124,6 +124,13 @@ data class BaseUITheme( val background: Brush, val foreground: Color, val highlight: Color, + /** + * The [SurfaceRole] this colour represents. When set, components overlay the + * matching [SurfaceStyle] material (gloss, bubbles, facets, specular band, grain) + * on top of [background], so buttons and other actionable items share the exact + * look of a [BaseSurface]. `null` means no material (e.g. ghost buttons). + */ + val surfaceRole: SurfaceRole? = null, ) data class CardTheme( @@ -132,6 +139,8 @@ data class BaseUITheme( val border: Border, val padding: PaddingValues, val shadow: Shadow, + /** The [SurfaceRole] the card renders, so it can overlay the matching material. */ + val surfaceRole: SurfaceRole? = null, ) data class ListRowTheme( @@ -232,16 +241,12 @@ fun rememberBaseUITheme( // highlight and translucent "glass" edges, lifted by soft, coloured // shadows — the aqua/glass language of Frutiger-Aero. // ------------------------------------------------------------------ - val glassTop = if (isLight) { - Color.White - } else { - lerp(colors.surfaceContainerHigh, Color.White, 0.10f) - } - val glassBottom = if (isLight) { - lerp(colors.surface, Color.White, 0.04f) - } else { - colors.surfaceContainer - } + // Primary surface (subtle) with the style's palette, and the secondary surface + // (slightly accented) used by cards and grouped content. + val panelTop = lerp(colors.backgroundPrimary, Color.White, if (isLight) 0.45f else 0.06f) + val panelBottom = colors.surfaceVariant + val secondaryTop = lerp(colors.backgroundSecondary, Color.White, if (isLight) 0.35f else 0.05f) + val secondaryBottom = colors.backgroundSecondary val glassPressed = if (isLight) { lerp(colors.surfaceContainerHighest, Color.White, 0.06f) } else { @@ -292,12 +297,12 @@ fun rememberBaseUITheme( // --- Glass (outline) ------------------------------------------------- val outlineColors = BaseUITheme.InteractionState( normal = BaseUITheme.ButtonColors( - background = glassBrush(glassTop, glassBottom), + background = glassBrush(panelTop, panelBottom), foreground = colors.onSurface, highlight = gloss, ), hovered = BaseUITheme.ButtonColors( - background = glassBrush(glassTop, glassBottom), + background = glassBrush(panelTop, panelBottom), foreground = colors.onSurface, highlight = Color.White.copy(alpha = if (isLight) 0.9f else 0.26f), ), @@ -307,11 +312,11 @@ fun rememberBaseUITheme( highlight = glossSoft, ), focused = BaseUITheme.ButtonColors( - background = glassBrush(glassTop, glassBottom), + background = glassBrush(panelTop, panelBottom), foreground = colors.onSurface, highlight = gloss, ), - ) + ).withRole(SurfaceRole.PrimaryBackground) val glassShadow = BaseUITheme.InteractionState( normal = BaseUITheme.Shadow( 8.dp, @@ -348,10 +353,10 @@ fun rememberBaseUITheme( // --- Aqua accent (primary) ------------------------------------------- val primaryEdge = lerp(colors.primary, Color.White, 0.5f) val primaryColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors(aqua(colors.primary), colors.onPrimary, gloss), + normal = BaseUITheme.ButtonColors(aqua(colors.accentPrimary), colors.onAccentPrimary, gloss), hovered = BaseUITheme.ButtonColors( - aqua(colors.primary), - colors.onPrimary, + aqua(colors.accentPrimary), + colors.onAccentPrimary, Color.White.copy(alpha = if (isLight) 0.9f else 0.3f), ), pressed = BaseUITheme.ButtonColors( @@ -359,11 +364,11 @@ fun rememberBaseUITheme( lerp(colors.primary, Color.Black, 0.16f), lerp(colors.primary, Color.Black, 0.24f), ), - colors.onPrimary, + colors.onAccentPrimary, glossSoft, ), - focused = BaseUITheme.ButtonColors(aqua(colors.primary), colors.onPrimary, gloss), - ) + focused = BaseUITheme.ButtonColors(aqua(colors.accentPrimary), colors.onAccentPrimary, gloss), + ).withRole(SurfaceRole.PrimaryAccent) val primaryShadow = BaseUITheme.InteractionState( normal = BaseUITheme.Shadow( 10.dp, @@ -398,7 +403,7 @@ fun rememberBaseUITheme( ) // --- Secondary (soft aqua glass) ------------------------------------- - val secondaryBase = colors.secondaryContainer + val secondaryBase = colors.backgroundSecondary val secondaryColors = BaseUITheme.InteractionState( normal = BaseUITheme.ButtonColors( glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase), @@ -423,7 +428,7 @@ fun rememberBaseUITheme( colors.onSecondaryContainer, gloss, ), - ) + ).withRole(SurfaceRole.SecondaryBackground) val secondaryShadow = BaseUITheme.InteractionState( normal = BaseUITheme.Shadow( 8.dp, @@ -592,10 +597,10 @@ fun rememberBaseUITheme( ), textField = TextFieldTheme( background = BaseUITheme.InteractionState( - normal = glassBrush(glassTop, glassBottom), - hovered = glassBrush(glassTop, glassBottom), + normal = glassBrush(panelTop, panelBottom), + hovered = glassBrush(panelTop, panelBottom), pressed = glassBrush(glassPressed, glassPressed), - focused = glassBrush(glassTop, glassBottom), + focused = glassBrush(panelTop, panelBottom), ), highlight = BaseUITheme.InteractionState( normal = gloss, @@ -647,7 +652,7 @@ fun rememberBaseUITheme( foreground = textFieldForeground, ), autoCompleteMenuTheme = AutoCompleteMenuTheme( - background = glassBrush(glassTop, glassBottom), + background = glassBrush(panelTop, panelBottom), shape = RoundedCornerShape(16.dp), border = BaseUITheme.Border(glassBorder, 1.dp), padding = PaddingValues(0.dp), @@ -716,7 +721,7 @@ fun rememberBaseUITheme( border = glassBorderState, padding = noPadding, ), - checkmarkColor = colors.onPrimary, + checkmarkColor = colors.onAccentPrimary, ), chipTab = ChipTabTheme( selected = BaseUITheme.ButtonStyle( @@ -783,7 +788,7 @@ fun rememberBaseUITheme( ), ), card = BaseUITheme.CardTheme( - background = glassBrush(glassTop, glassBottom), + background = glassBrush(secondaryTop, secondaryBottom), shape = RoundedCornerShape(20.dp), border = BaseUITheme.Border(glassBorder, 1.dp), padding = PaddingValues(0.dp), @@ -793,20 +798,18 @@ fun rememberBaseUITheme( shadowColor.copy(alpha = 0.16f), shadowColor.copy(alpha = 0.2f) ), + surfaceRole = SurfaceRole.SecondaryBackground, ), listRowTile = BaseUITheme.ListRowTheme( background = BaseUITheme.AdvancedInteractionState( normal = glassBrush(Color.Transparent, Color.Transparent), - hovered = glassBrush(glassTop, glassBottom), + hovered = glassBrush(panelTop, panelBottom), pressed = glassBrush(glassPressed, glassPressed), - focused = glassBrush(glassTop, glassBottom), - selected = glassBrush( - lerp(colors.primaryContainer, Color.White, 0.4f), - colors.primaryContainer, - ), + focused = glassBrush(panelTop, panelBottom), + selected = glassBrush(secondaryTop, secondaryBottom), disabled = glassBrush( - glassTop.copy(alpha = 0.5f), - glassBottom.copy(alpha = 0.5f), + panelTop.copy(alpha = 0.5f), + panelBottom.copy(alpha = 0.5f), ), ), shape = BaseUITheme.AdvancedInteractionState( @@ -857,7 +860,7 @@ fun rememberBaseUITheme( ), ), dialog = BaseUITheme.DialogTheme( - background = glassBrush(glassTop, glassBottom), + background = glassBrush(panelTop, panelBottom), scrim = Color.Black.copy(alpha = 0.42f), shape = RoundedCornerShape(24.dp), border = BaseUITheme.Border(glassEdge, 1.dp), @@ -870,7 +873,7 @@ fun rememberBaseUITheme( padding = PaddingValues(24.dp), ), dropdownMenu = BaseUITheme.DropdownMenuTheme( - background = glassBrush(glassTop, glassBottom), + background = glassBrush(panelTop, panelBottom), shape = RoundedCornerShape(16.dp), border = BaseUITheme.Border(glassBorder, 1.dp), shadow = BaseUITheme.Shadow( @@ -879,7 +882,7 @@ fun rememberBaseUITheme( shadowColor.copy(alpha = 0.22f), shadowColor.copy(alpha = 0.28f) ), - itemHoverBackground = glassBrush(glassTop, glassBottom), + itemHoverBackground = glassBrush(panelTop, panelBottom), itemForeground = colors.onSurface, itemHighlight = gloss, padding = PaddingValues(6.dp), @@ -905,6 +908,16 @@ fun BaseUITheme.ButtonStyle.copyPadding(padding: PaddingValues): BaseUITheme.But fun BaseUITheme.ButtonStyle.copyColors(colors: BaseUITheme.ButtonColors): BaseUITheme.ButtonStyle = copy(colors = BaseUITheme.InteractionState.fromSingleValue(colors)) +/** Tags every interaction state of a button colour set with the surface role it renders. */ +private fun BaseUITheme.InteractionState.withRole( + role: SurfaceRole, +): BaseUITheme.InteractionState = BaseUITheme.InteractionState( + normal = normal.copy(surfaceRole = role), + hovered = hovered.copy(surfaceRole = role), + pressed = pressed.copy(surfaceRole = role), + focused = focused.copy(surfaceRole = role), +) + @Composable fun invertedButtonStyle(): BaseUITheme.ButtonStyle { val scheme = MaterialTheme.colorScheme 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 index ef3ddb83..607ef0cb 100644 --- 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 @@ -184,8 +184,9 @@ fun Toggle( ) .clip(resolved.shape) .background(resolved.colors.background, resolved.shape) + .applySurfaceMaterial(resolved.colors) .border(BorderStroke(resolved.border.width, borderColor), resolved.shape) - .highlight(resolved.colors.highlight) + .applyHighlight(resolved.colors) .then( if (onCheckedChange != null) { Modifier diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt index 1e5d69cc..633d1f96 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt @@ -18,13 +18,25 @@ package dev.krtirtho.spotube.core.ui.theming import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import com.materialkolor.rememberDynamicColorScheme +import dev.krtirtho.spotube.core.ui.base.BaseUIColorScheme +import dev.krtirtho.spotube.core.ui.base.baseUIColorScheme import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.UserSettings +/** + * The app's single source of truth for colour. + * + * The selected [UserSettings.surfaceTheme] and [UserSettings.accentColor] produce a + * [BaseUIColorScheme], and every Material role is then overridden from it. That way the + * whole app — Material components included — renders in the chosen surface theme's colours + * and accent, instead of Material's dynamic palette drifting away from the base UI. + */ @Composable fun SpotubeTheme( settings: UserSettings, @@ -35,6 +47,7 @@ fun SpotubeTheme( Theme.DARK -> true Theme.SYSTEM -> isSystemInDarkTheme() } + val isLight = !isDarkTheme val accent = if (isDarkTheme) { settings.accentColor.toDarkColor() @@ -42,15 +55,56 @@ fun SpotubeTheme( settings.accentColor.toLightColor() } - val colorScheme = rememberDynamicColorScheme( + val baseColors = remember(settings.surfaceTheme, isLight, accent) { + baseUIColorScheme(settings.surfaceTheme, isLight, accent) + } + + val dynamicScheme = rememberDynamicColorScheme( seedColor = accent, isDark = isDarkTheme, ) MaterialTheme( - colorScheme = colorScheme, + colorScheme = dynamicScheme.mergeWithBaseColors(baseColors), ) { Surface(content = content) } } +/** + * Overrides the accent/surface roles of a dynamically generated [ColorScheme] with the + * base UI palette, so Material components share the exact same colours as the base + * components and the surface theme's primary/secondary backgrounds. + */ +private fun ColorScheme.mergeWithBaseColors(base: BaseUIColorScheme): ColorScheme = copy( + primary = base.accentPrimary, + onPrimary = base.onAccentPrimary, + primaryContainer = base.primaryContainer, + onPrimaryContainer = base.onPrimaryContainer, + inversePrimary = base.actionInverse, + secondary = base.accentSecondary, + onSecondary = base.onAccentSecondary, + secondaryContainer = base.secondaryContainer, + onSecondaryContainer = base.onSecondaryContainer, + background = base.surface, + onBackground = base.onBackground, + surface = base.surface, + onSurface = base.onSurface, + surfaceVariant = base.surfaceVariant, + onSurfaceVariant = base.onSurfaceVariant, + surfaceTint = base.accentPrimary, + surfaceBright = base.surfaceContainerHigh, + surfaceDim = base.surfaceContainerHighest, + surfaceContainerLowest = base.surface, + surfaceContainerLow = base.surfaceContainer, + surfaceContainer = base.surfaceContainer, + surfaceContainerHigh = base.surfaceContainerHigh, + surfaceContainerHighest = base.surfaceContainerHighest, + inverseSurface = base.backgroundInverse, + inverseOnSurface = base.onBackgroundInverse, + outline = base.outline, + outlineVariant = base.outlineVariant, + error = base.error, + onError = base.onError, + scrim = base.scrim, +) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt new file mode 100644 index 00000000..489a8b9f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt @@ -0,0 +1,70 @@ +/* + * 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.modules.settings + +import androidx.compose.ui.graphics.Color +import dev.krtirtho.spotube.core.ui.base.SurfaceStyle + +/** + * Accent palettes that are known to pair well with each surface material. + * + * The appearance picker only offers these combinations, so a user can never end up with a + * clashing surface + accent pair — instead of the old behaviour where any accent could be + * applied to any surface. + */ +val SurfaceStyle.recommendedAccents: List + get() = when (this) { + SurfaceStyle.Watery -> listOf( + AccentColors.OCEANIC_CYAN, + AccentColors.MIDNIGHT_BLUE, + AccentColors.GREEN_GOBLIN, + AccentColors.SUNSET_ORANGE, + ) + + SurfaceStyle.Icy -> listOf( + AccentColors.MIDNIGHT_BLUE, + AccentColors.OCEANIC_CYAN, + AccentColors.ELECTRIC_VIOLET, + AccentColors.ROSE_GARDEN, + ) + + SurfaceStyle.Chrome -> listOf( + AccentColors.METALLIC_SLATE, + AccentColors.MIDNIGHT_BLUE, + AccentColors.SUNSET_ORANGE, + AccentColors.GREEN_GOBLIN, + ) + + SurfaceStyle.Acrylic -> listOf( + AccentColors.METALLIC_SLATE, + AccentColors.ELECTRIC_VIOLET, + AccentColors.OCEANIC_CYAN, + AccentColors.ROSE_GARDEN, + ) + + SurfaceStyle.Glass -> listOf( + AccentColors.GREEN_GOBLIN, + AccentColors.ELECTRIC_VIOLET, + AccentColors.OCEANIC_CYAN, + AccentColors.ROSE_GARDEN, + ) + } + +/** The concrete accent colour for the current light/dark mode. */ +fun AccentColors.colorFor(isLight: Boolean): Color = + if (isLight) toLightColor() else toDarkColor() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt index 54331e6f..260cd7d3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt @@ -23,45 +23,48 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize 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.lazy.LazyListScope -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape 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.graphics.Color +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import compose.icons.FeatherIcons -import compose.icons.feathericons.Droplet -import compose.icons.feathericons.Monitor -import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.core.ui.base.BaseSurface +import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors 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.SurfaceRole import dev.krtirtho.spotube.core.ui.base.SurfaceStyle +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.core.ui.base.baseUIColorScheme import dev.krtirtho.spotube.modules.settings.AccentColors import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.UserSettings +import dev.krtirtho.spotube.modules.settings.colorFor import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard import dev.krtirtho.spotube.modules.settings.components.SettingCardItem +import dev.krtirtho.spotube.modules.settings.recommendedAccents import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch -import dev.krtirtho.spotube.resources.iconsax.IconsaxColorsSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* @@ -97,39 +100,23 @@ internal fun LazyListScope.appearanceSection( ) }, { - SelectionSettingCard( - title = stringResource(Res.string.settings_surface_theme_title), - subtitle = stringResource( - Res.string.settings_surface_theme_subtitle_current, - settings.surfaceTheme.displayLabel() - ), + // Surface material and accent are one decision: each surface only offers the + // accents that are known to pair with it, so the result is always a curated, + // good-looking combination instead of an arbitrary one. + AppearanceSettingCard( + selectedSurface = settings.surfaceTheme, + selectedAccent = settings.accentColor, icon = { SettingsItemIcon( Iconsax.IconsaxMagic, - stringResource(Res.string.settings_surface_theme_title) + stringResource(Res.string.settings_appearance_title) ) }, - selectedOption = settings.surfaceTheme, - options = SurfaceStyle.entries, - optionLabel = { it.displayLabel() }, - onOptionSelected = { surfaceTheme -> + onSaved = { surface, accent -> settingsViewModel.updateSettings { - copy(surfaceTheme = surfaceTheme) + copy(surfaceTheme = surface, accentColor = accent) } - } - ) - }, - { - AccentColorSettingCard( - selectedAccent = settings.accentColor, - icon = { - SettingsItemIcon(Iconsax.IconsaxColorsSquare, stringResource(Res.string.settings_accent_title)) }, - onColorSaved = { accent -> - settingsViewModel.updateSettings { - copy(accentColor = accent) - } - } ) }, ) @@ -170,43 +157,53 @@ private fun AccentColors.displayLabel(): String { } @Composable -private fun AccentColorSettingCard( +private fun AppearanceSettingCard( + selectedSurface: SurfaceStyle, selectedAccent: AccentColors, icon: (@Composable () -> Unit)? = null, - onColorSaved: (AccentColors) -> Unit, + onSaved: (SurfaceStyle, AccentColors) -> Unit, ) { var isDialogOpen by remember { mutableStateOf(false) } + val isLight = LocalBaseUIColors.current.isLight SettingCardItem( - title = stringResource(Res.string.settings_accent_title), + title = stringResource(Res.string.settings_appearance_title), subtitle = stringResource( - Res.string.settings_accent_subtitle_current, - selectedAccent.displayLabel() + Res.string.settings_appearance_subtitle_current, + selectedSurface.displayLabel(), + selectedAccent.displayLabel(), ), icon = icon, trailingContent = { - AccentDualPreview( - lightAccent = selectedAccent.toLightColor(), - darkAccent = selectedAccent.toDarkColor(), + AppearanceTrailingSwatch( + surface = selectedSurface, + accent = selectedAccent, + isLight = isLight, ) }, - onClick = { - isDialogOpen = true - } + onClick = { isDialogOpen = true }, ) if (isDialogOpen) { - var draftAccent by remember(selectedAccent, isDialogOpen) { mutableStateOf(selectedAccent) } + var draftSurface by remember(selectedSurface, isDialogOpen) { + mutableStateOf(selectedSurface) + } + var draftAccent by remember(selectedSurface, selectedAccent, isDialogOpen) { + mutableStateOf(selectedAccent) + } ThemedDialog( onDismissRequest = { isDialogOpen = false }, title = { - Text(stringResource(Res.string.settings_accent_dialog_title), style = MaterialTheme.typography.titleLarge) + Text( + stringResource(Res.string.settings_appearance_dialog_title), + style = MaterialTheme.typography.titleLarge, + ) }, actions = { PrimaryButton( onClick = { - onColorSaved(draftAccent) + onSaved(draftSurface, draftAccent) isDialogOpen = false } ) { @@ -217,141 +214,245 @@ private fun AccentColorSettingCard( } }, ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Text( - text = stringResource(Res.string.settings_accent_dialog_description), + text = stringResource(Res.string.settings_appearance_dialog_description), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Text( + text = stringResource(Res.string.settings_appearance_surface_label), + style = MaterialTheme.typography.labelLarge, + ) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(8.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) - ) + SurfaceStyle.entries.forEach { style -> + SurfaceStyleTile( + style = style, + accent = draftAccent, + isLight = isLight, + selected = style == draftSurface, + modifier = Modifier.weight(1f), + onClick = { + draftSurface = style + if (draftAccent !in style.recommendedAccents) { + draftAccent = style.recommendedAccents.first() + } + }, + ) + } } - 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(), - ) - } + Text( + text = stringResource(Res.string.settings_appearance_accent_label), + style = MaterialTheme.typography.labelLarge, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + draftSurface.recommendedAccents.forEach { accent -> + AccentSwatch( + accent = accent, + label = accent.displayLabel(), + isLight = isLight, + selected = accent == draftAccent, + onClick = { draftAccent = accent }, + ) } } + + AppearancePreview( + surface = draftSurface, + accent = draftAccent, + isLight = isLight, + ) + } + } + } +} + +@Composable +private fun SurfaceStyleTile( + style: SurfaceStyle, + accent: AccentColors, + isLight: Boolean, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val scheme = remember(style, accent, isLight) { + baseUIColorScheme(style, isLight, accent.colorFor(isLight)) + } + Box( + modifier = modifier + .height(70.dp) + .then( + if (selected) { + Modifier + .border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(14.dp)) + .padding(2.dp) + } else { + Modifier.padding(2.dp) + } + ), + ) { + CompositionLocalProvider(LocalBaseUIColors provides scheme) { + BaseSurface( + style = style, + role = SurfaceRole.PrimaryBackground, + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + contentPadding = PaddingValues(8.dp), + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = style.displayLabel(), + style = MaterialTheme.typography.labelMedium, + ) + Box( + modifier = Modifier + .size(14.dp) + .background(accent.colorFor(isLight), CircleShape), + ) + } } } } } @Composable -private fun AccentDualPreview( - lightAccent: Color, - darkAccent: Color, +private fun AccentSwatch( + accent: AccentColors, + label: String, + isLight: Boolean, + selected: Boolean, + onClick: () -> Unit, ) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Box( - modifier = Modifier - .size(20.dp) - .background(Color.White, RoundedCornerShape(6.dp)) - .border(1.dp, Color(0x22000000), RoundedCornerShape(6.dp)), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .size(10.dp) - .background(lightAccent, CircleShape) + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(accent.colorFor(isLight)) + .border( + width = if (selected) 3.dp else 1.dp, + color = if (selected) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.outlineVariant + }, + shape = CircleShape, ) - } - Box( - modifier = Modifier - .size(20.dp) - .background(Color(0xFF121212), RoundedCornerShape(6.dp)) - .border(1.dp, Color(0x33FFFFFF), RoundedCornerShape(6.dp)), - contentAlignment = Alignment.Center + .clickable(onClick = onClick) + .semantics { contentDescription = label }, + ) +} + +@Composable +private fun AppearanceTrailingSwatch( + surface: SurfaceStyle, + accent: AccentColors, + isLight: Boolean, +) { + val scheme = remember(surface, accent, isLight) { + baseUIColorScheme(surface, isLight, accent.colorFor(isLight)) + } + CompositionLocalProvider(LocalBaseUIColors provides scheme) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = Modifier - .size(10.dp) - .background(darkAccent, CircleShape) - ) + BaseSurface( + style = surface, + role = SurfaceRole.PrimaryBackground, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.size(width = 46.dp, height = 28.dp), + ) {} + BaseSurface( + style = surface, + role = SurfaceRole.PrimaryAccent, + shape = CircleShape, + modifier = Modifier.size(18.dp), + ) {} } } } @Composable -private fun AccentThemePreview( - title: String, - accent: Color, - background: Color, - textColor: Color, - modifier: Modifier = Modifier, +private fun AppearancePreview( + surface: SurfaceStyle, + accent: AccentColors, + isLight: Boolean, ) { - Column( - modifier = modifier - .background(background, RoundedCornerShape(10.dp)) - .border(1.dp, textColor.copy(alpha = 0.16f), RoundedCornerShape(10.dp)) - .padding(10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text(title, style = MaterialTheme.typography.labelMedium, color = textColor) - Box( + val scheme = remember(surface, accent, isLight) { + baseUIColorScheme(surface, isLight, accent.colorFor(isLight)) + } + CompositionLocalProvider(LocalBaseUIColors provides scheme) { + BaseSurface( + style = surface, + role = SurfaceRole.PrimaryBackground, + shape = RoundedCornerShape(16.dp), modifier = Modifier .fillMaxWidth() - .height(6.dp) - .background(accent, RoundedCornerShape(999.dp)) - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + .height(150.dp), + contentPadding = PaddingValues(14.dp), ) { - Box( - modifier = Modifier - .size(14.dp) - .background(accent, CircleShape) - ) - Text( - stringResource(Res.string.settings_preview_label), - style = MaterialTheme.typography.bodySmall, - color = textColor - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - stringResource(Res.string.settings_preview_button), - style = MaterialTheme.typography.labelSmall, - color = accent - ) + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringResource(Res.string.settings_preview_label), + style = MaterialTheme.typography.labelLarge, + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + BaseSurface( + style = surface, + role = SurfaceRole.PrimaryAccent, + shape = RoundedCornerShape(999.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + stringResource(Res.string.settings_preview_button), + style = MaterialTheme.typography.labelMedium, + ) + } + BaseSurface( + style = surface, + role = SurfaceRole.SecondaryAccent, + shape = RoundedCornerShape(999.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + stringResource(Res.string.settings_preview_secondary), + style = MaterialTheme.typography.labelMedium, + ) + } + } + BaseSurface( + style = surface, + role = SurfaceRole.SecondaryBackground, + shape = RoundedCornerShape(10.dp), + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(10.dp), + ) { + Text( + text = stringResource(Res.string.settings_preview_label), + style = MaterialTheme.typography.bodySmall, + ) + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt index eaabb5e9..561704f3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt @@ -47,6 +47,8 @@ import dev.krtirtho.spotube.bottombarTabs import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.ui.base.BaseSurface +import dev.krtirtho.spotube.core.ui.base.SurfaceRole import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator @Composable @@ -55,12 +57,10 @@ fun AppBottombar( navigationState: NavigationState, modifier: Modifier = Modifier, ) { - Surface( - modifier = modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars), + BaseSurface( + role = SurfaceRole.PrimaryBackground, shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 3.dp, - shadowElevation = 10.dp, + modifier = modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars), ) { Row( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt index 59178e07..b628831c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt @@ -36,7 +36,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -70,8 +69,10 @@ import org.koin.core.parameter.parametersOf import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.ui.base.BaseUITheme +import dev.krtirtho.spotube.core.ui.base.BaseSurface import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme +import dev.krtirtho.spotube.core.ui.base.SurfaceRole // The floating player on small screens that appears above the floating AppBottombar // @@ -115,14 +116,12 @@ fun AppFloatingPlayer( } } - Surface( + BaseSurface( + role = SurfaceRole.InverseBackground, + shape = RoundedCornerShape(24.dp, 24.dp), modifier = modifier .fillMaxWidth() .height(76.dp), - shape = RoundedCornerShape(24.dp, 24.dp), - color = MaterialTheme.colorScheme.inverseSurface, - contentColor = MaterialTheme.colorScheme.inverseOnSurface, - tonalElevation = 3.dp, ) { Box( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index f9032b9a..1adb2dbb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -72,8 +72,10 @@ import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.Slider +import dev.krtirtho.spotube.core.ui.base.SurfaceRole import dev.krtirtho.spotube.core.ui.base.VariableIconButton import dev.krtirtho.spotube.core.ui.base.VariableIconButtonVariant +import dev.krtirtho.spotube.core.ui.base.baseSurfaceTexture import dev.krtirtho.spotube.core.ui.base.copyShape import dev.krtirtho.spotube.core.ui.base.invertedButtonStyle import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon @@ -210,7 +212,9 @@ fun AppLargePlayer( } } - val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f) + // A translucent tint (not near-opaque) so the blurred art behind still reads through, and + // the material's watery caustics sit on top of it. + val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f) Surface( modifier = modifier @@ -234,6 +238,7 @@ fun AppLargePlayer( ) } } + .baseSurfaceTexture(SurfaceRole.PrimaryBackground) ) { Row( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index dd61c618..e5dfbbf8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -55,6 +55,8 @@ import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.GhostIconButton +import dev.krtirtho.spotube.core.ui.base.SurfaceRole +import dev.krtirtho.spotube.core.ui.base.baseSurfaceMaterial import dev.krtirtho.spotube.core.ui.component.SidebarItem import dev.krtirtho.spotube.core.ui.component.SidebarSectionLabel import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar @@ -88,7 +90,8 @@ fun AppSidebar( Column( modifier = modifier .fillMaxHeight() - .width(width), + .width(width) + .baseSurfaceMaterial(role = SurfaceRole.PrimaryBackground), horizontalAlignment = Alignment.CenterHorizontally ) { Column(modifier = Modifier.weight(1f)) {