feat(ui): new custom surface integration on all components

This commit is contained in:
Kingkor Roy Tirtho 2026-10-09 14:51:30 +06:00
parent 9f96b07ebd
commit 963b360a81
18 changed files with 1065 additions and 538 deletions

View File

@ -68,6 +68,14 @@
<string name="settings_accent_midnight_blue">Midnight Blue</string> <string name="settings_accent_midnight_blue">Midnight Blue</string>
<string name="settings_accent_metallic_slate">Metallic Slate</string> <string name="settings_accent_metallic_slate">Metallic Slate</string>
<string name="settings_appearance_title">Look &amp; feel</string>
<string name="settings_appearance_subtitle_current">Surface material and accent. Current: %1$s · %2$s</string>
<string name="settings_appearance_dialog_title">Look &amp; feel</string>
<string name="settings_appearance_dialog_description">Pick a surface material, then an accent that pairs with it. Every option is a curated combination.</string>
<string name="settings_appearance_surface_label">Surface</string>
<string name="settings_appearance_accent_label">Accent</string>
<string name="settings_preview_secondary">Secondary</string>
<string name="settings_download_folder_title">Download folder override</string> <string name="settings_download_folder_title">Download folder override</string>
<string name="settings_download_folder_current">Current folder: %1$s</string> <string name="settings_download_folder_current">Current folder: %1$s</string>
<string name="settings_download_folder_default">Current folder: default music folder</string> <string name="settings_download_folder_default">Current folder: default music folder</string>

View File

@ -22,11 +22,13 @@ import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
@ -157,7 +159,16 @@ fun App(
} }
SpotubeTheme(settings = userSettings) { 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( CompositionLocalProvider(
LocalBaseUITheme provides baseUITheme, LocalBaseUITheme provides baseUITheme,
LocalBaseUIColors provides baseUITheme.colors, LocalBaseUIColors provides baseUITheme.colors,

View File

@ -55,6 +55,18 @@ data class BaseUIColorScheme(
val onPrimaryContainer: Color, val onPrimaryContainer: Color,
val secondaryContainer: Color, val secondaryContainer: Color,
val onSecondaryContainer: 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 outline: Color,
val outlineVariant: Color, val outlineVariant: Color,
val error: Color, val error: Color,
@ -79,12 +91,52 @@ fun rememberBaseUIColorScheme(
accentColor: Color? = null, accentColor: Color? = null,
): BaseUIColorScheme { ): BaseUIColorScheme {
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f 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) { return remember(surfaceTheme, isLight, accent) {
baseUIColorScheme(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<Float, Float, Float> {
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( fun baseUIColorScheme(
surfaceTheme: SurfaceStyle, surfaceTheme: SurfaceStyle,
isLight: Boolean, isLight: Boolean,
@ -99,6 +151,21 @@ fun baseUIColorScheme(
lerp(accent, Color.White, 0.7f) lerp(accent, Color.White, 0.7f)
} }
val secondaryContainer = lerp(palette.surface, accent, if (isLight) 0.22f else 0.34f) 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( return BaseUIColorScheme(
surfaceTheme = surfaceTheme, surfaceTheme = surfaceTheme,
@ -120,6 +187,18 @@ fun baseUIColorScheme(
onPrimaryContainer = onPrimaryContainer, onPrimaryContainer = onPrimaryContainer,
secondaryContainer = secondaryContainer, secondaryContainer = secondaryContainer,
onSecondaryContainer = palette.onSurface, 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, outline = palette.outline,
outlineVariant = palette.outlineVariant, outlineVariant = palette.outlineVariant,
error = if (isLight) Color(0xFFC0392B) else Color(0xFFE57373), error = if (isLight) Color(0xFFC0392B) else Color(0xFFE57373),
@ -128,7 +207,7 @@ fun baseUIColorScheme(
) )
} }
private class SurfacePalette( internal class SurfacePalette(
val backgroundTop: Color, val backgroundTop: Color,
val backgroundBottom: Color, val backgroundBottom: Color,
val surface: Color, val surface: Color,
@ -143,7 +222,7 @@ private class SurfacePalette(
val outlineVariant: Color, val outlineVariant: Color,
) )
private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette { internal fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette {
return if (isLight) { return if (isLight) {
when (style) { when (style) {
SurfaceStyle.Glass -> SurfacePalette( SurfaceStyle.Glass -> SurfacePalette(
@ -177,18 +256,18 @@ private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalett
) )
SurfaceStyle.Watery -> SurfacePalette( SurfaceStyle.Watery -> SurfacePalette(
backgroundTop = Color(0xFFD6F0FF), backgroundTop = Color(0xFFEAF7FE),
backgroundBottom = Color(0xFFB4DFFA), backgroundBottom = Color(0xFFD8EFFB),
surface = Color(0xFFF0FAFF), surface = Color(0xFFF0FAFF),
surfaceVariant = Color(0xFFDCF0FC), surfaceVariant = Color(0xFFE2F2FB),
surfaceContainer = Color(0xFFE7F5FE), surfaceContainer = Color(0xFFEAF6FD),
surfaceContainerHigh = Color(0xFFEFF9FF), surfaceContainerHigh = Color(0xFFF2FAFE),
surfaceContainerHighest = Color(0xFFCFE9F8), surfaceContainerHighest = Color(0xFFD8ECF8),
onBackground = Color(0xFF08324A), onBackground = Color(0xFF08324A),
onSurface = Color(0xFF08324A), onSurface = Color(0xFF08324A),
onSurfaceVariant = Color(0xFF3A6884), onSurfaceVariant = Color(0xFF3A6884),
outline = Color(0xFF8FBBD4), outline = Color(0xFF9EC4DA),
outlineVariant = Color(0xFFC6E2F2), outlineVariant = Color(0xFFD0E7F4),
) )
SurfaceStyle.Icy -> SurfacePalette( SurfaceStyle.Icy -> SurfacePalette(
@ -254,8 +333,8 @@ private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalett
) )
SurfaceStyle.Watery -> SurfacePalette( SurfaceStyle.Watery -> SurfacePalette(
backgroundTop = Color(0xFF06121E), backgroundTop = Color(0xFF0A1622),
backgroundBottom = Color(0xFF0B1F31), backgroundBottom = Color(0xFF0E1E2D),
surface = Color(0xFF0E2233), surface = Color(0xFF0E2233),
surfaceVariant = Color(0xFF153148), surfaceVariant = Color(0xFF153148),
surfaceContainer = Color(0xFF0B1D2C), surfaceContainer = Color(0xFF0B1D2C),

View File

@ -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 @Composable
fun OutlineButton( fun OutlineButton(
onClick: () -> Unit, onClick: () -> Unit,
@ -172,6 +193,7 @@ fun OutlineButton(
state.colors.background, state.colors.background,
state.shape state.shape
) )
.applySurfaceMaterial(state.colors)
.border(BorderStroke(state.border.width, state.border.color), state.shape) .border(BorderStroke(state.border.width, state.border.color), state.shape)
) )
.clickable( .clickable(
@ -180,7 +202,7 @@ fun OutlineButton(
indication = ripple(), indication = ripple(),
onClick = onClick, 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), .padding(state.padding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -221,6 +243,7 @@ fun PrimaryButton(
.applyShadow(state.shadow, state.shape) .applyShadow(state.shadow, state.shape)
.clip(state.shape) .clip(state.shape)
.background(state.colors.background, state.shape) .background(state.colors.background, state.shape)
.applySurfaceMaterial(state.colors)
.border(BorderStroke(state.border.width, state.border.color), state.shape) .border(BorderStroke(state.border.width, state.border.color), state.shape)
.clickable( .clickable(
enabled = enabled, enabled = enabled,
@ -228,7 +251,7 @@ fun PrimaryButton(
indication = ripple(), indication = ripple(),
onClick = onClick, onClick = onClick,
) )
.highlight(state.colors.highlight) .applyHighlight(state.colors)
.padding(state.padding), .padding(state.padding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -267,6 +290,7 @@ fun SecondaryButton(
.applyShadow(state.shadow, state.shape) .applyShadow(state.shadow, state.shape)
.clip(state.shape) .clip(state.shape)
.background(state.colors.background, state.shape) .background(state.colors.background, state.shape)
.applySurfaceMaterial(state.colors)
.border(BorderStroke(state.border.width, state.border.color), state.shape) .border(BorderStroke(state.border.width, state.border.color), state.shape)
.clickable( .clickable(
enabled = enabled, enabled = enabled,
@ -274,7 +298,7 @@ fun SecondaryButton(
indication = ripple(), indication = ripple(),
onClick = onClick, onClick = onClick,
) )
.highlight(state.colors.highlight) .applyHighlight(state.colors)
.padding(state.padding), .padding(state.padding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {

View File

@ -75,6 +75,7 @@ fun Card(
) )
.clip(cardTheme.shape) .clip(cardTheme.shape)
.background(cardTheme.background, cardTheme.shape) .background(cardTheme.background, cardTheme.shape)
.applySurfaceMaterial(cardTheme.surfaceRole)
.border(BorderStroke(cardTheme.border.width, cardTheme.border.color), cardTheme.shape) .border(BorderStroke(cardTheme.border.width, cardTheme.border.color), cardTheme.shape)
.padding(cardTheme.padding), .padding(cardTheme.padding),
) { ) {

View File

@ -146,8 +146,9 @@ fun CheckBox(
) )
.clip(resolved.shape) .clip(resolved.shape)
.background(resolved.colors.background, resolved.shape) .background(resolved.colors.background, resolved.shape)
.applySurfaceMaterial(resolved.colors)
.border(BorderStroke(0.5.dp, borderColor), resolved.shape) .border(BorderStroke(0.5.dp, borderColor), resolved.shape)
.highlight(resolved.colors.highlight) .applyHighlight(resolved.colors)
.then( .then(
if (onClick != null) { if (onClick != null) {
Modifier Modifier

View File

@ -123,6 +123,7 @@ fun ChipTab(
.applyChipShadow(state.shadow, state.shape) .applyChipShadow(state.shadow, state.shape)
.clip(state.shape) .clip(state.shape)
.background(state.colors.background, state.shape) .background(state.colors.background, state.shape)
.applySurfaceMaterial(state.colors)
.border(BorderStroke(state.border.width, state.border.color), state.shape) .border(BorderStroke(state.border.width, state.border.color), state.shape)
.clickable( .clickable(
enabled = enabled, enabled = enabled,
@ -130,7 +131,7 @@ fun ChipTab(
indication = ripple(), indication = ripple(),
onClick = onClick, onClick = onClick,
) )
.highlight(state.colors.highlight) .applyHighlight(state.colors)
.padding(state.padding), .padding(state.padding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {

View File

@ -39,12 +39,14 @@ import androidx.compose.ui.unit.dp
/** /**
* A theme-aware replacement for [Scaffold]. * A theme-aware replacement for [Scaffold].
* *
* Material's [Scaffold] paints Material's own surface colour, which hides the selected * A scaffold is always the current theme's **Primary Background** — there is no option to
* [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass). [BaseScaffold] instead: * 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 * [containerColor] is a narrow escape hatch for the rare screen that paints its own
* - paints a chosen [surface] material locally, or * backdrop (e.g. onboarding) and needs the scaffold to stay out of the way; it is not a
* - uses an explicit [containerColor] when one is given. * surface-style switch.
* *
* It keeps the same slot structure as [Scaffold] (top/bottom bars, snackbar host, * It keeps the same slot structure as [Scaffold] (top/bottom bars, snackbar host,
* floating action button) and hands the resolved [PaddingValues] to [content]. * floating action button) and hands the resolved [PaddingValues] to [content].
@ -56,7 +58,6 @@ fun BaseScaffold(
bottomBar: @Composable () -> Unit = {}, bottomBar: @Composable () -> Unit = {},
snackbarHost: @Composable () -> Unit = {}, snackbarHost: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {}, floatingActionButton: @Composable () -> Unit = {},
surface: SurfaceStyle? = null,
containerColor: Color? = null, containerColor: Color? = null,
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
content: @Composable (PaddingValues) -> Unit, content: @Composable (PaddingValues) -> Unit,
@ -70,15 +71,9 @@ fun BaseScaffold(
containerColor = Color.Transparent, containerColor = Color.Transparent,
contentWindowInsets = contentWindowInsets, contentWindowInsets = contentWindowInsets,
) { innerPadding -> ) { innerPadding ->
val backgroundModifier = when { val colors = LocalBaseUIColors.current
containerColor != null -> Modifier.background(containerColor) val backgroundModifier = containerColor?.let { Modifier.background(it) }
surface != null -> { ?: Modifier.baseSurfaceBackdrop(colors.surfaceTheme, colors.isLight)
val isLight = LocalBaseUIColors.current.isLight
Modifier.baseSurfaceBackdrop(surface, isLight)
}
else -> Modifier
}
Box(modifier = Modifier.fillMaxSize().then(backgroundModifier)) { Box(modifier = Modifier.fillMaxSize().then(backgroundModifier)) {
content(innerPadding) content(innerPadding)
} }
@ -90,9 +85,7 @@ fun BaseScaffold(
@Composable @Composable
private fun BaseScaffoldPreview() { private fun BaseScaffoldPreview() {
BaseUIPreview { BaseUIPreview {
val style = LocalBaseUITheme.current.surfaceTheme
BaseScaffold( BaseScaffold(
surface = style,
topBar = { topBar = {
Text( Text(
text = "Title", text = "Title",

View File

@ -35,6 +35,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -48,7 +49,8 @@ import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.DrawScope 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.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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 * Distinct from [androidx.compose.material3.Surface] — this draws decorative material
* backgrounds (gradients, gloss, grain, bubbles, facets) and provides a matching * backgrounds (gradients, gloss, grain, bubbles, facets) and provides a matching
@ -85,7 +111,8 @@ enum class SurfaceStyle {
@Composable @Composable
fun BaseSurface( fun BaseSurface(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
style: SurfaceStyle = SurfaceStyle.Glass, style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme,
role: SurfaceRole = SurfaceRole.PrimaryBackground,
shape: Shape = RectangleShape, shape: Shape = RectangleShape,
elevation: Dp? = null, elevation: Dp? = null,
contentColor: Color? = null, contentColor: Color? = null,
@ -93,63 +120,60 @@ fun BaseSurface(
contentAlignment: Alignment = Alignment.TopStart, contentAlignment: Alignment = Alignment.TopStart,
content: @Composable BoxScope.() -> Unit, content: @Composable BoxScope.() -> Unit,
) { ) {
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f val ambientScheme = LocalBaseUIColors.current
val onSurface = MaterialTheme.colorScheme.onSurface 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.Watery -> 14.dp
SurfaceStyle.Icy -> 12.dp SurfaceStyle.Icy -> 12.dp
SurfaceStyle.Chrome -> 10.dp SurfaceStyle.Chrome -> 10.dp
SurfaceStyle.Acrylic -> 16.dp SurfaceStyle.Acrylic -> 16.dp
SurfaceStyle.Glass -> 12.dp SurfaceStyle.Glass -> 12.dp
} }
val resolvedContentColor = contentColor ?: when (style) { val resolvedElevation = elevation ?: when (role) {
SurfaceStyle.Watery -> Color.White SurfaceRole.PrimaryBackground -> baseElevation
SurfaceStyle.Icy -> if (isLight) Color(0xFF0B3A55) else Color(0xFFDCEBF7) SurfaceRole.SecondaryBackground -> baseElevation + 2.dp
SurfaceStyle.Chrome -> if (isLight) Color(0xFF1B2733) else Color(0xFFE7ECF2) SurfaceRole.PrimaryAccent, SurfaceRole.SecondaryAccent -> baseElevation + 6.dp
SurfaceStyle.Acrylic, SurfaceStyle.Glass -> onSurface SurfaceRole.InverseBackground -> baseElevation
SurfaceRole.InverseAction -> baseElevation + 6.dp
} }
val ambient = when (style) { val resolvedContentColor = contentColor ?: when (role) {
SurfaceStyle.Watery -> Color(0xFF0E6FB0).copy(alpha = 0.38f) SurfaceRole.PrimaryBackground -> scheme.onBackgroundPrimary
SurfaceStyle.Icy -> Color(0xFF3FA9E0).copy(alpha = 0.3f) SurfaceRole.SecondaryBackground -> scheme.onBackgroundSecondary
SurfaceStyle.Chrome -> Color.Black.copy(alpha = 0.3f) SurfaceRole.PrimaryAccent -> scheme.onAccentPrimary
SurfaceStyle.Acrylic -> onSurface.copy(alpha = 0.22f) SurfaceRole.SecondaryAccent -> scheme.onAccentSecondary
SurfaceStyle.Glass -> onSurface.copy(alpha = 0.18f) SurfaceRole.InverseBackground -> scheme.onBackgroundInverse
SurfaceRole.InverseAction -> scheme.onActionInverse
} }
val spot = ambient.copy(alpha = (ambient.alpha + 0.08f).coerceAtMost(1f)) val ambient = when (role) {
SurfaceRole.PrimaryAccent -> scheme.accentPrimary.copy(alpha = 0.42f)
val borderBrush = when (style) { SurfaceRole.SecondaryAccent -> scheme.accentSecondary.copy(alpha = 0.42f)
SurfaceStyle.Watery -> Brush.verticalGradient( SurfaceRole.InverseAction -> scheme.actionInverse.copy(alpha = 0.42f)
listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)), SurfaceRole.InverseBackground -> Color.Black.copy(alpha = 0.3f)
) else -> palette.onSurface.copy(alpha = 0.2f)
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 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 // 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 // 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. // shape. Accent and secondary/inverse roles are filled, so they can cast one.
val castsShadow = style != SurfaceStyle.Glass && style != SurfaceStyle.Acrylic val translucent = role == SurfaceRole.PrimaryBackground &&
(style == SurfaceStyle.Glass || style == SurfaceStyle.Acrylic)
CompositionLocalProvider(LocalContentColor provides resolvedContentColor) { CompositionLocalProvider(LocalContentColor provides resolvedContentColor) {
Box( Box(
modifier = modifier modifier = modifier
.then( .then(
if (castsShadow) { if (!translucent) {
Modifier.shadow( Modifier.shadow(
elevation = resolvedElevation, elevation = resolvedElevation,
shape = shape, shape = shape,
@ -162,7 +186,7 @@ fun BaseSurface(
) )
.clip(shape) .clip(shape)
.drawWithCache { .drawWithCache {
onDrawBehind { drawSurfaceMaterial(style, isLight) } onDrawBehind { drawSurfaceMaterial(style, role, isLight, scheme) }
} }
.border(BorderStroke(1.dp, borderBrush), shape) .border(BorderStroke(1.dp, borderBrush), shape)
.padding(contentPadding), .padding(contentPadding),
@ -173,199 +197,313 @@ fun BaseSurface(
} }
/** /**
* Paints the full-bleed material of [style] as a page background: the material body plus * Paints the full-bleed material of [style] as a page background: the primary material
* its own sheen and reflections, but no panel bevel (a backdrop has no edges). * body plus its sheen and reflections, but no panel bevel (a backdrop has no edges).
*/ */
fun Modifier.baseSurfaceBackdrop(style: SurfaceStyle, isLight: Boolean): Modifier = fun Modifier.baseSurfaceBackdrop(style: SurfaceStyle, isLight: Boolean): Modifier =
drawWithCache { drawWithCache {
onDrawBehind { drawSurfaceBase(style, isLight) } onDrawBehind {
} drawStyleBase(style, isLight)
drawStyleDecorations(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()
} }
drawPath(path, frost.copy(alpha = if (isLight) 0.25f else 0.12f))
} }
// Frost speckles. /**
repeat(90) { * Overlays a [SurfaceStyle]'s material character (gloss, bubbles, facets, specular band,
drawCircle( * grain) and its inner bevel on a node that already carries a base fill. Compact components
color = frost.copy(alpha = if (isLight) 0.5f else 0.28f), * — buttons, toggles, chips — use this to share the exact look of a [BaseSurface] in [role]
radius = 0.8f, * without redrawing the fill (which comes from their own interaction-state brush).
center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), */
) 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) { /** Ambient-scheme overload of [baseSurfaceTexture] for a single role. */
val colors = if (isLight) { @Composable
listOf(Color(0xFFA6E3FF), Color(0xFF3FB6EA), Color(0xFF0E6FB0)) fun Modifier.baseSurfaceTexture(role: SurfaceRole): Modifier {
} else { val scheme = LocalBaseUIColors.current
// Moonlit night water: deep blue with a pale silver sheen. return baseSurfaceTexture(scheme.surfaceTheme, role, scheme.isLight)
listOf(Color(0xFF1C3A5E), Color(0xFF102942), Color(0xFF071827)) }
/**
* 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( drawRect(
brush = Brush.verticalGradient( brush = Brush.verticalGradient(
colors = listOf(sheen.copy(alpha = if (isLight) 0.35f else 0.22f), Color.Transparent), listOf(
startY = 0f, lerp(accent, Color.White, 0.45f),
endY = size.height * 0.5f, accent,
lerp(accent, Color.Black, 0.16f),
),
), ),
) )
}
val random = Random(53) // --- Material decorations (one per style) ------------------------------------
repeat(5) {
val rx = size.width * (0.12f + random.nextFloat() * 0.2f) private fun DrawScope.drawStyleDecorations(style: SurfaceStyle, isLight: Boolean) {
val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f) when (style) {
val cy = rx * 0.5f + random.nextFloat() * (size.height * 0.6f - rx).coerceAtLeast(0f) SurfaceStyle.Glass -> {
drawOval( drawGloss(if (isLight) 0.55f else 0.16f)
brush = Brush.radialGradient( drawBottomReflection(if (isLight) 0.4f else 0.1f)
colors = listOf(sheen.copy(alpha = if (isLight) 0.3f else 0.18f), Color.Transparent), drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f)
center = Offset(cx, cy), }
radius = rx,
), SurfaceStyle.Acrylic -> {
topLeft = Offset(cx - rx, cy - rx * 0.5f), // Fine grain so the panel reads as a translucent acrylic sheet.
size = Size(rx * 2f, rx), 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) { 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) val random = Random(seed)
repeat(count) { repeat(count) {
val radius = size.minDimension * (0.05f + random.nextFloat() * 0.14f) 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, radius = radius,
center = center, center = center,
) )
} if (rim) {
} drawCircle(
color = Color.White.copy(alpha = alpha * 0.7f),
@BaseUIPhonePreview radius = radius,
@BaseUIDesktopPreview center = center,
@Composable style = Stroke(width = (radius * 0.14f).coerceAtLeast(0.8f)),
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,
)
}
}
} }
} }
} }
@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 @Composable
private fun BaseSurfaceGridPreview() { private fun SurfacePreview() {
BaseUIPreview { BaseUIPreview {
val colors = LocalBaseUIColors.current
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(20.dp), .padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
SurfaceStyle.entries.chunked(2).forEach { rowStyles -> SurfaceStyle.entries.forEach { style ->
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
rowStyles.forEach { style -> SurfaceRole.entries.forEach { role ->
BaseSurface( BaseSurface(
style = style, style = style,
role = role,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(140.dp), .height(84.dp),
contentPadding = PaddingValues(10.dp),
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -483,54 +659,41 @@ private fun BaseSurfaceGridPreview() {
) { ) {
Text( Text(
text = style.name, text = style.name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Text( Text(
text = "Frutiger Aero", text = role.name,
style = MaterialTheme.typography.labelMedium, 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),
)
}
}
}
}

View File

@ -124,6 +124,13 @@ data class BaseUITheme(
val background: Brush, val background: Brush,
val foreground: Color, val foreground: Color,
val highlight: 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( data class CardTheme(
@ -132,6 +139,8 @@ data class BaseUITheme(
val border: Border, val border: Border,
val padding: PaddingValues, val padding: PaddingValues,
val shadow: Shadow, val shadow: Shadow,
/** The [SurfaceRole] the card renders, so it can overlay the matching material. */
val surfaceRole: SurfaceRole? = null,
) )
data class ListRowTheme( data class ListRowTheme(
@ -232,16 +241,12 @@ fun rememberBaseUITheme(
// highlight and translucent "glass" edges, lifted by soft, coloured // highlight and translucent "glass" edges, lifted by soft, coloured
// shadows — the aqua/glass language of Frutiger-Aero. // shadows — the aqua/glass language of Frutiger-Aero.
// ------------------------------------------------------------------ // ------------------------------------------------------------------
val glassTop = if (isLight) { // Primary surface (subtle) with the style's palette, and the secondary surface
Color.White // (slightly accented) used by cards and grouped content.
} else { val panelTop = lerp(colors.backgroundPrimary, Color.White, if (isLight) 0.45f else 0.06f)
lerp(colors.surfaceContainerHigh, Color.White, 0.10f) val panelBottom = colors.surfaceVariant
} val secondaryTop = lerp(colors.backgroundSecondary, Color.White, if (isLight) 0.35f else 0.05f)
val glassBottom = if (isLight) { val secondaryBottom = colors.backgroundSecondary
lerp(colors.surface, Color.White, 0.04f)
} else {
colors.surfaceContainer
}
val glassPressed = if (isLight) { val glassPressed = if (isLight) {
lerp(colors.surfaceContainerHighest, Color.White, 0.06f) lerp(colors.surfaceContainerHighest, Color.White, 0.06f)
} else { } else {
@ -292,12 +297,12 @@ fun rememberBaseUITheme(
// --- Glass (outline) ------------------------------------------------- // --- Glass (outline) -------------------------------------------------
val outlineColors = BaseUITheme.InteractionState( val outlineColors = BaseUITheme.InteractionState(
normal = BaseUITheme.ButtonColors( normal = BaseUITheme.ButtonColors(
background = glassBrush(glassTop, glassBottom), background = glassBrush(panelTop, panelBottom),
foreground = colors.onSurface, foreground = colors.onSurface,
highlight = gloss, highlight = gloss,
), ),
hovered = BaseUITheme.ButtonColors( hovered = BaseUITheme.ButtonColors(
background = glassBrush(glassTop, glassBottom), background = glassBrush(panelTop, panelBottom),
foreground = colors.onSurface, foreground = colors.onSurface,
highlight = Color.White.copy(alpha = if (isLight) 0.9f else 0.26f), highlight = Color.White.copy(alpha = if (isLight) 0.9f else 0.26f),
), ),
@ -307,11 +312,11 @@ fun rememberBaseUITheme(
highlight = glossSoft, highlight = glossSoft,
), ),
focused = BaseUITheme.ButtonColors( focused = BaseUITheme.ButtonColors(
background = glassBrush(glassTop, glassBottom), background = glassBrush(panelTop, panelBottom),
foreground = colors.onSurface, foreground = colors.onSurface,
highlight = gloss, highlight = gloss,
), ),
) ).withRole(SurfaceRole.PrimaryBackground)
val glassShadow = BaseUITheme.InteractionState( val glassShadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow( normal = BaseUITheme.Shadow(
8.dp, 8.dp,
@ -348,10 +353,10 @@ fun rememberBaseUITheme(
// --- Aqua accent (primary) ------------------------------------------- // --- Aqua accent (primary) -------------------------------------------
val primaryEdge = lerp(colors.primary, Color.White, 0.5f) val primaryEdge = lerp(colors.primary, Color.White, 0.5f)
val primaryColors = BaseUITheme.InteractionState( 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( hovered = BaseUITheme.ButtonColors(
aqua(colors.primary), aqua(colors.accentPrimary),
colors.onPrimary, colors.onAccentPrimary,
Color.White.copy(alpha = if (isLight) 0.9f else 0.3f), Color.White.copy(alpha = if (isLight) 0.9f else 0.3f),
), ),
pressed = BaseUITheme.ButtonColors( pressed = BaseUITheme.ButtonColors(
@ -359,11 +364,11 @@ fun rememberBaseUITheme(
lerp(colors.primary, Color.Black, 0.16f), lerp(colors.primary, Color.Black, 0.16f),
lerp(colors.primary, Color.Black, 0.24f), lerp(colors.primary, Color.Black, 0.24f),
), ),
colors.onPrimary, colors.onAccentPrimary,
glossSoft, 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( val primaryShadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow( normal = BaseUITheme.Shadow(
10.dp, 10.dp,
@ -398,7 +403,7 @@ fun rememberBaseUITheme(
) )
// --- Secondary (soft aqua glass) ------------------------------------- // --- Secondary (soft aqua glass) -------------------------------------
val secondaryBase = colors.secondaryContainer val secondaryBase = colors.backgroundSecondary
val secondaryColors = BaseUITheme.InteractionState( val secondaryColors = BaseUITheme.InteractionState(
normal = BaseUITheme.ButtonColors( normal = BaseUITheme.ButtonColors(
glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase), glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase),
@ -423,7 +428,7 @@ fun rememberBaseUITheme(
colors.onSecondaryContainer, colors.onSecondaryContainer,
gloss, gloss,
), ),
) ).withRole(SurfaceRole.SecondaryBackground)
val secondaryShadow = BaseUITheme.InteractionState( val secondaryShadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow( normal = BaseUITheme.Shadow(
8.dp, 8.dp,
@ -592,10 +597,10 @@ fun rememberBaseUITheme(
), ),
textField = TextFieldTheme( textField = TextFieldTheme(
background = BaseUITheme.InteractionState( background = BaseUITheme.InteractionState(
normal = glassBrush(glassTop, glassBottom), normal = glassBrush(panelTop, panelBottom),
hovered = glassBrush(glassTop, glassBottom), hovered = glassBrush(panelTop, panelBottom),
pressed = glassBrush(glassPressed, glassPressed), pressed = glassBrush(glassPressed, glassPressed),
focused = glassBrush(glassTop, glassBottom), focused = glassBrush(panelTop, panelBottom),
), ),
highlight = BaseUITheme.InteractionState( highlight = BaseUITheme.InteractionState(
normal = gloss, normal = gloss,
@ -647,7 +652,7 @@ fun rememberBaseUITheme(
foreground = textFieldForeground, foreground = textFieldForeground,
), ),
autoCompleteMenuTheme = AutoCompleteMenuTheme( autoCompleteMenuTheme = AutoCompleteMenuTheme(
background = glassBrush(glassTop, glassBottom), background = glassBrush(panelTop, panelBottom),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
border = BaseUITheme.Border(glassBorder, 1.dp), border = BaseUITheme.Border(glassBorder, 1.dp),
padding = PaddingValues(0.dp), padding = PaddingValues(0.dp),
@ -716,7 +721,7 @@ fun rememberBaseUITheme(
border = glassBorderState, border = glassBorderState,
padding = noPadding, padding = noPadding,
), ),
checkmarkColor = colors.onPrimary, checkmarkColor = colors.onAccentPrimary,
), ),
chipTab = ChipTabTheme( chipTab = ChipTabTheme(
selected = BaseUITheme.ButtonStyle( selected = BaseUITheme.ButtonStyle(
@ -783,7 +788,7 @@ fun rememberBaseUITheme(
), ),
), ),
card = BaseUITheme.CardTheme( card = BaseUITheme.CardTheme(
background = glassBrush(glassTop, glassBottom), background = glassBrush(secondaryTop, secondaryBottom),
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
border = BaseUITheme.Border(glassBorder, 1.dp), border = BaseUITheme.Border(glassBorder, 1.dp),
padding = PaddingValues(0.dp), padding = PaddingValues(0.dp),
@ -793,20 +798,18 @@ fun rememberBaseUITheme(
shadowColor.copy(alpha = 0.16f), shadowColor.copy(alpha = 0.16f),
shadowColor.copy(alpha = 0.2f) shadowColor.copy(alpha = 0.2f)
), ),
surfaceRole = SurfaceRole.SecondaryBackground,
), ),
listRowTile = BaseUITheme.ListRowTheme( listRowTile = BaseUITheme.ListRowTheme(
background = BaseUITheme.AdvancedInteractionState( background = BaseUITheme.AdvancedInteractionState(
normal = glassBrush(Color.Transparent, Color.Transparent), normal = glassBrush(Color.Transparent, Color.Transparent),
hovered = glassBrush(glassTop, glassBottom), hovered = glassBrush(panelTop, panelBottom),
pressed = glassBrush(glassPressed, glassPressed), pressed = glassBrush(glassPressed, glassPressed),
focused = glassBrush(glassTop, glassBottom), focused = glassBrush(panelTop, panelBottom),
selected = glassBrush( selected = glassBrush(secondaryTop, secondaryBottom),
lerp(colors.primaryContainer, Color.White, 0.4f),
colors.primaryContainer,
),
disabled = glassBrush( disabled = glassBrush(
glassTop.copy(alpha = 0.5f), panelTop.copy(alpha = 0.5f),
glassBottom.copy(alpha = 0.5f), panelBottom.copy(alpha = 0.5f),
), ),
), ),
shape = BaseUITheme.AdvancedInteractionState( shape = BaseUITheme.AdvancedInteractionState(
@ -857,7 +860,7 @@ fun rememberBaseUITheme(
), ),
), ),
dialog = BaseUITheme.DialogTheme( dialog = BaseUITheme.DialogTheme(
background = glassBrush(glassTop, glassBottom), background = glassBrush(panelTop, panelBottom),
scrim = Color.Black.copy(alpha = 0.42f), scrim = Color.Black.copy(alpha = 0.42f),
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
border = BaseUITheme.Border(glassEdge, 1.dp), border = BaseUITheme.Border(glassEdge, 1.dp),
@ -870,7 +873,7 @@ fun rememberBaseUITheme(
padding = PaddingValues(24.dp), padding = PaddingValues(24.dp),
), ),
dropdownMenu = BaseUITheme.DropdownMenuTheme( dropdownMenu = BaseUITheme.DropdownMenuTheme(
background = glassBrush(glassTop, glassBottom), background = glassBrush(panelTop, panelBottom),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
border = BaseUITheme.Border(glassBorder, 1.dp), border = BaseUITheme.Border(glassBorder, 1.dp),
shadow = BaseUITheme.Shadow( shadow = BaseUITheme.Shadow(
@ -879,7 +882,7 @@ fun rememberBaseUITheme(
shadowColor.copy(alpha = 0.22f), shadowColor.copy(alpha = 0.22f),
shadowColor.copy(alpha = 0.28f) shadowColor.copy(alpha = 0.28f)
), ),
itemHoverBackground = glassBrush(glassTop, glassBottom), itemHoverBackground = glassBrush(panelTop, panelBottom),
itemForeground = colors.onSurface, itemForeground = colors.onSurface,
itemHighlight = gloss, itemHighlight = gloss,
padding = PaddingValues(6.dp), 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 = fun BaseUITheme.ButtonStyle.copyColors(colors: BaseUITheme.ButtonColors): BaseUITheme.ButtonStyle =
copy(colors = BaseUITheme.InteractionState.fromSingleValue(colors)) 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<BaseUITheme.ButtonColors>.withRole(
role: SurfaceRole,
): BaseUITheme.InteractionState<BaseUITheme.ButtonColors> = BaseUITheme.InteractionState(
normal = normal.copy(surfaceRole = role),
hovered = hovered.copy(surfaceRole = role),
pressed = pressed.copy(surfaceRole = role),
focused = focused.copy(surfaceRole = role),
)
@Composable @Composable
fun invertedButtonStyle(): BaseUITheme.ButtonStyle { fun invertedButtonStyle(): BaseUITheme.ButtonStyle {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme

View File

@ -184,8 +184,9 @@ fun Toggle(
) )
.clip(resolved.shape) .clip(resolved.shape)
.background(resolved.colors.background, resolved.shape) .background(resolved.colors.background, resolved.shape)
.applySurfaceMaterial(resolved.colors)
.border(BorderStroke(resolved.border.width, borderColor), resolved.shape) .border(BorderStroke(resolved.border.width, borderColor), resolved.shape)
.highlight(resolved.colors.highlight) .applyHighlight(resolved.colors)
.then( .then(
if (onCheckedChange != null) { if (onCheckedChange != null) {
Modifier Modifier

View File

@ -18,13 +18,25 @@
package dev.krtirtho.spotube.core.ui.theming package dev.krtirtho.spotube.core.ui.theming
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.materialkolor.rememberDynamicColorScheme 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.Theme
import dev.krtirtho.spotube.modules.settings.UserSettings 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 @Composable
fun SpotubeTheme( fun SpotubeTheme(
settings: UserSettings, settings: UserSettings,
@ -35,6 +47,7 @@ fun SpotubeTheme(
Theme.DARK -> true Theme.DARK -> true
Theme.SYSTEM -> isSystemInDarkTheme() Theme.SYSTEM -> isSystemInDarkTheme()
} }
val isLight = !isDarkTheme
val accent = if (isDarkTheme) { val accent = if (isDarkTheme) {
settings.accentColor.toDarkColor() settings.accentColor.toDarkColor()
@ -42,15 +55,56 @@ fun SpotubeTheme(
settings.accentColor.toLightColor() settings.accentColor.toLightColor()
} }
val colorScheme = rememberDynamicColorScheme( val baseColors = remember(settings.surfaceTheme, isLight, accent) {
baseUIColorScheme(settings.surfaceTheme, isLight, accent)
}
val dynamicScheme = rememberDynamicColorScheme(
seedColor = accent, seedColor = accent,
isDark = isDarkTheme, isDark = isDarkTheme,
) )
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = dynamicScheme.mergeWithBaseColors(baseColors),
) { ) {
Surface(content = content) 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,
)

View File

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

View File

@ -23,45 +23,48 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.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 androidx.compose.ui.unit.dp
import compose.icons.FeatherIcons import dev.krtirtho.spotube.core.ui.base.BaseSurface
import compose.icons.feathericons.Droplet import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
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.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.Radio import dev.krtirtho.spotube.core.ui.base.SurfaceRole
import dev.krtirtho.spotube.core.ui.base.SurfaceStyle 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.AccentColors
import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.Theme
import dev.krtirtho.spotube.modules.settings.UserSettings 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.SelectionSettingCard
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch
import dev.krtirtho.spotube.resources.iconsax.IconsaxColorsSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
@ -97,39 +100,23 @@ internal fun LazyListScope.appearanceSection(
) )
}, },
{ {
SelectionSettingCard( // Surface material and accent are one decision: each surface only offers the
title = stringResource(Res.string.settings_surface_theme_title), // accents that are known to pair with it, so the result is always a curated,
subtitle = stringResource( // good-looking combination instead of an arbitrary one.
Res.string.settings_surface_theme_subtitle_current, AppearanceSettingCard(
settings.surfaceTheme.displayLabel() selectedSurface = settings.surfaceTheme,
), selectedAccent = settings.accentColor,
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
Iconsax.IconsaxMagic, Iconsax.IconsaxMagic,
stringResource(Res.string.settings_surface_theme_title) stringResource(Res.string.settings_appearance_title)
) )
}, },
selectedOption = settings.surfaceTheme, onSaved = { surface, accent ->
options = SurfaceStyle.entries,
optionLabel = { it.displayLabel() },
onOptionSelected = { surfaceTheme ->
settingsViewModel.updateSettings { 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 @Composable
private fun AccentColorSettingCard( private fun AppearanceSettingCard(
selectedSurface: SurfaceStyle,
selectedAccent: AccentColors, selectedAccent: AccentColors,
icon: (@Composable () -> Unit)? = null, icon: (@Composable () -> Unit)? = null,
onColorSaved: (AccentColors) -> Unit, onSaved: (SurfaceStyle, AccentColors) -> Unit,
) { ) {
var isDialogOpen by remember { mutableStateOf(false) } var isDialogOpen by remember { mutableStateOf(false) }
val isLight = LocalBaseUIColors.current.isLight
SettingCardItem( SettingCardItem(
title = stringResource(Res.string.settings_accent_title), title = stringResource(Res.string.settings_appearance_title),
subtitle = stringResource( subtitle = stringResource(
Res.string.settings_accent_subtitle_current, Res.string.settings_appearance_subtitle_current,
selectedAccent.displayLabel() selectedSurface.displayLabel(),
selectedAccent.displayLabel(),
), ),
icon = icon, icon = icon,
trailingContent = { trailingContent = {
AccentDualPreview( AppearanceTrailingSwatch(
lightAccent = selectedAccent.toLightColor(), surface = selectedSurface,
darkAccent = selectedAccent.toDarkColor(), accent = selectedAccent,
isLight = isLight,
) )
}, },
onClick = { onClick = { isDialogOpen = true },
isDialogOpen = true
}
) )
if (isDialogOpen) { 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( ThemedDialog(
onDismissRequest = { isDialogOpen = false }, onDismissRequest = { isDialogOpen = false },
title = { title = {
Text(stringResource(Res.string.settings_accent_dialog_title), style = MaterialTheme.typography.titleLarge) Text(
stringResource(Res.string.settings_appearance_dialog_title),
style = MaterialTheme.typography.titleLarge,
)
}, },
actions = { actions = {
PrimaryButton( PrimaryButton(
onClick = { onClick = {
onColorSaved(draftAccent) onSaved(draftSurface, draftAccent)
isDialogOpen = false isDialogOpen = false
} }
) { ) {
@ -217,141 +214,245 @@ private fun AccentColorSettingCard(
} }
}, },
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text( Text(
text = stringResource(Res.string.settings_accent_dialog_description), text = stringResource(Res.string.settings_appearance_dialog_description),
style = MaterialTheme.typography.bodySmall, 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
AccentThemePreview( SurfaceStyle.entries.forEach { style ->
title = stringResource(Res.string.settings_preview_light), SurfaceStyleTile(
accent = draftAccent.toLightColor(), style = style,
background = Color(0xFFFFFFFF), accent = draftAccent,
textColor = Color(0xFF121212), isLight = isLight,
modifier = Modifier.weight(1f) selected = style == draftSurface,
) modifier = Modifier.weight(1f),
AccentThemePreview( onClick = {
title = stringResource(Res.string.settings_preview_dark), draftSurface = style
accent = draftAccent.toDarkColor(), if (draftAccent !in style.recommendedAccents) {
background = Color(0xFF121212), draftAccent = style.recommendedAccents.first()
textColor = Color(0xFFEDEDED), }
modifier = Modifier.weight(1f) },
) )
}
} }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(
AccentColors.entries.forEach { option -> text = stringResource(Res.string.settings_appearance_accent_label),
Row( style = MaterialTheme.typography.labelLarge,
modifier = Modifier )
.fillMaxWidth() Row(
.clickable { draftAccent = option } modifier = Modifier.fillMaxWidth(),
.padding(horizontal = 4.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically, ) {
horizontalArrangement = Arrangement.spacedBy(8.dp) draftSurface.recommendedAccents.forEach { accent ->
) { AccentSwatch(
Radio( accent = accent,
selected = option == draftAccent, label = accent.displayLabel(),
onClick = { draftAccent = option } isLight = isLight,
) selected = accent == draftAccent,
Text( onClick = { draftAccent = accent },
text = option.displayLabel(), )
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
AccentDualPreview(
lightAccent = option.toLightColor(),
darkAccent = option.toDarkColor(),
)
}
} }
} }
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 @Composable
private fun AccentDualPreview( private fun AccentSwatch(
lightAccent: Color, accent: AccentColors,
darkAccent: Color, label: String,
isLight: Boolean,
selected: Boolean,
onClick: () -> Unit,
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Box(
Box( modifier = Modifier
modifier = Modifier .size(34.dp)
.size(20.dp) .clip(CircleShape)
.background(Color.White, RoundedCornerShape(6.dp)) .background(accent.colorFor(isLight))
.border(1.dp, Color(0x22000000), RoundedCornerShape(6.dp)), .border(
contentAlignment = Alignment.Center width = if (selected) 3.dp else 1.dp,
) { color = if (selected) {
Box( MaterialTheme.colorScheme.onSurface
modifier = Modifier } else {
.size(10.dp) MaterialTheme.colorScheme.outlineVariant
.background(lightAccent, CircleShape) },
shape = CircleShape,
) )
} .clickable(onClick = onClick)
Box( .semantics { contentDescription = label },
modifier = Modifier )
.size(20.dp) }
.background(Color(0xFF121212), RoundedCornerShape(6.dp))
.border(1.dp, Color(0x33FFFFFF), RoundedCornerShape(6.dp)), @Composable
contentAlignment = Alignment.Center 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( BaseSurface(
modifier = Modifier style = surface,
.size(10.dp) role = SurfaceRole.PrimaryBackground,
.background(darkAccent, CircleShape) 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 @Composable
private fun AccentThemePreview( private fun AppearancePreview(
title: String, surface: SurfaceStyle,
accent: Color, accent: AccentColors,
background: Color, isLight: Boolean,
textColor: Color,
modifier: Modifier = Modifier,
) { ) {
Column( val scheme = remember(surface, accent, isLight) {
modifier = modifier baseUIColorScheme(surface, isLight, accent.colorFor(isLight))
.background(background, RoundedCornerShape(10.dp)) }
.border(1.dp, textColor.copy(alpha = 0.16f), RoundedCornerShape(10.dp)) CompositionLocalProvider(LocalBaseUIColors provides scheme) {
.padding(10.dp), BaseSurface(
verticalArrangement = Arrangement.spacedBy(8.dp) style = surface,
) { role = SurfaceRole.PrimaryBackground,
Text(title, style = MaterialTheme.typography.labelMedium, color = textColor) shape = RoundedCornerShape(16.dp),
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(6.dp) .height(150.dp),
.background(accent, RoundedCornerShape(999.dp)) contentPadding = PaddingValues(14.dp),
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Box( Column(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.size(14.dp) verticalArrangement = Arrangement.spacedBy(10.dp),
.background(accent, CircleShape) ) {
) Text(
Text( text = stringResource(Res.string.settings_preview_label),
stringResource(Res.string.settings_preview_label), style = MaterialTheme.typography.labelLarge,
style = MaterialTheme.typography.bodySmall, )
color = textColor Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
) BaseSurface(
Spacer(modifier = Modifier.weight(1f)) style = surface,
Text( role = SurfaceRole.PrimaryAccent,
stringResource(Res.string.settings_preview_button), shape = RoundedCornerShape(999.dp),
style = MaterialTheme.typography.labelSmall, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
color = accent ) {
) 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,
)
}
}
} }
} }
} }

View File

@ -47,6 +47,8 @@ import dev.krtirtho.spotube.bottombarTabs
import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes 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 import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
@Composable @Composable
@ -55,12 +57,10 @@ fun AppBottombar(
navigationState: NavigationState, navigationState: NavigationState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Surface( BaseSurface(
modifier = modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars), role = SurfaceRole.PrimaryBackground,
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars),
tonalElevation = 3.dp,
shadowElevation = 10.dp,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier

View File

@ -36,7 +36,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -70,8 +69,10 @@ import org.koin.core.parameter.parametersOf
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.ui.base.BaseUITheme 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.IconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme 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 // 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 modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(76.dp), .height(76.dp),
shape = RoundedCornerShape(24.dp, 24.dp),
color = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
tonalElevation = 3.dp,
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier

View File

@ -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.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.Slider 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.VariableIconButton
import dev.krtirtho.spotube.core.ui.base.VariableIconButtonVariant 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.copyShape
import dev.krtirtho.spotube.core.ui.base.invertedButtonStyle import dev.krtirtho.spotube.core.ui.base.invertedButtonStyle
import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon 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( Surface(
modifier = modifier modifier = modifier
@ -234,6 +238,7 @@ fun AppLargePlayer(
) )
} }
} }
.baseSurfaceTexture(SurfaceRole.PrimaryBackground)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier

View File

@ -55,6 +55,8 @@ import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.GhostIconButton 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.SidebarItem
import dev.krtirtho.spotube.core.ui.component.SidebarSectionLabel import dev.krtirtho.spotube.core.ui.component.SidebarSectionLabel
import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar
@ -88,7 +90,8 @@ fun AppSidebar(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxHeight() .fillMaxHeight()
.width(width), .width(width)
.baseSurfaceMaterial(role = SurfaceRole.PrimaryBackground),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {