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)) {