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_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_current">Current folder: %1$s</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.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,

View File

@ -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<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(
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),

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

View File

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

View File

@ -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

View File

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

View File

@ -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",

View File

@ -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,32 +197,97 @@ 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) }
onDrawBehind {
drawStyleBase(style, isLight)
drawStyleDecorations(style, isLight)
}
}
private fun DrawScope.drawSurfaceMaterial(style: SurfaceStyle, isLight: Boolean) {
drawSurfaceBase(style, isLight)
drawInnerBevel(isLight)
/**
* 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)
}
}
private fun DrawScope.drawSurfaceBase(style: SurfaceStyle, isLight: Boolean) {
/** 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) }
}
}
private fun DrawScope.drawSurfaceMaterial(
style: SurfaceStyle,
role: SurfaceRole,
isLight: Boolean,
scheme: BaseUIColorScheme,
) {
// Role base fill.
when (role) {
SurfaceRole.PrimaryBackground -> drawStyleBase(style, isLight)
// 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.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.
SurfaceStyle.Glass -> {
val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6)
drawRect(
brush = Brush.verticalGradient(
@ -208,13 +297,9 @@ private fun DrawScope.drawGlass(isLight: Boolean) {
),
),
)
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.
SurfaceStyle.Acrylic -> {
val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8)
drawRect(
brush = Brush.verticalGradient(
@ -224,7 +309,79 @@ private fun DrawScope.drawAcrylic(isLight: Boolean) {
),
),
)
}
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(
listOf(
lerp(accent, Color.White, 0.45f),
accent,
lerp(accent, Color.Black, 0.16f),
),
),
)
}
// --- 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) {
@ -240,48 +397,20 @@ private fun DrawScope.drawAcrylic(isLight: Boolean) {
)
}
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),
),
)
SurfaceStyle.Chrome -> {
// Hard specular reflection near the top.
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.White.copy(alpha = 0.85f), Color.Transparent),
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,
),
)
} 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(
@ -290,17 +419,9 @@ private fun DrawScope.drawChrome(isLight: Boolean) {
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))
SurfaceStyle.Icy -> {
val frost = if (isLight) Color.White else Color(0xFFB8CCE0)
// Crystalline facets (kept inset so they are never clipped by the shape).
@ -327,45 +448,62 @@ private fun DrawScope.drawIcy(isLight: Boolean) {
)
}
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))
}
drawRect(brush = Brush.verticalGradient(colors))
val sheen = if (isLight) Color.White else Color(0xFFB9D2F0)
SurfaceStyle.Watery -> {
val sheen = if (isLight) Color.White else Color(0xFFAFD3F2)
// Caustic / moonlight pooling from the surface.
// Caustic light pooling from the surface.
drawRect(
brush = Brush.verticalGradient(
colors = listOf(sheen.copy(alpha = if (isLight) 0.35f else 0.22f), Color.Transparent),
colors = listOf(
sheen.copy(alpha = if (isLight) 0.6f else 0.2f),
Color.Transparent,
),
startY = 0f,
endY = size.height * 0.5f,
endY = size.height * 0.55f,
),
)
val random = Random(53)
repeat(5) {
val rx = size.width * (0.12f + random.nextFloat() * 0.2f)
// 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 * 0.5f + random.nextFloat() * (size.height * 0.6f - 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.3f else 0.18f), Color.Transparent),
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.5f),
size = Size(rx * 2f, rx),
topLeft = Offset(cx - rx, cy - rx * 0.7f),
size = Size(rx * 2f, rx * 1.4f),
)
}
drawBubbles(seed = 71, count = 6, alpha = if (isLight) 0.4f else 0.22f)
// 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)
}
}
}
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))
}
}
}
}
}
}
@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),
// 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
.fillMaxWidth()
.height(66.dp)
.clip(RoundedCornerShape(14.dp))
.baseSurfaceBackdrop(style, isLight),
contentAlignment = Alignment.CenterStart,
.weight(1f)
.height(60.dp)
.clip(RoundedCornerShape(12.dp))
.baseSurfaceBackdrop(style, colors.isLight),
contentAlignment = Alignment.Center,
) {
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),
style = MaterialTheme.typography.labelSmall,
color = colors.onSurface,
)
}
}
}
}
}
}

View File

@ -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<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
fun invertedButtonStyle(): BaseUITheme.ButtonStyle {
val scheme = MaterialTheme.colorScheme

View File

@ -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

View File

@ -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,
)

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.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(),
)
}
}
}
}
}
}
}
@Composable
private fun AccentDualPreview(
lightAccent: Color,
darkAccent: Color,
) {
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(20.dp)
.background(Color(0xFF121212), RoundedCornerShape(6.dp))
.border(1.dp, Color(0x33FFFFFF), RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(10.dp)
.background(darkAccent, CircleShape)
)
}
}
}
@Composable
private fun AccentThemePreview(
title: String,
accent: Color,
background: Color,
textColor: Color,
modifier: Modifier = Modifier,
) {
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(
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.background(accent, RoundedCornerShape(999.dp))
text = stringResource(Res.string.settings_appearance_accent_label),
style = MaterialTheme.typography.labelLarge,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
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, 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
.background(accent.colorFor(isLight), CircleShape),
)
}
}
}
}
}
@Composable
private fun AccentSwatch(
accent: AccentColors,
label: String,
isLight: Boolean,
selected: Boolean,
onClick: () -> Unit,
) {
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,
)
.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,
) {
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 AppearancePreview(
surface: SurfaceStyle,
accent: AccentColors,
isLight: Boolean,
) {
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(150.dp),
contentPadding = PaddingValues(14.dp),
) {
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,
)
}
}
}
}
}

View File

@ -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

View File

@ -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

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.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

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