Compare commits

..

1 Commits

Author SHA1 Message Date
Jonatan Nyberg
5e099c029e
Merge b0327fd224 into 9f96b07ebd 2026-10-08 05:47:16 +03:00
18 changed files with 537 additions and 1064 deletions

View File

@ -68,14 +68,6 @@
<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,13 +22,11 @@ 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
@ -159,16 +157,7 @@ fun App(
} }
SpotubeTheme(settings = userSettings) { SpotubeTheme(settings = userSettings) {
val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f val baseUITheme = rememberBaseUITheme(surfaceTheme = userSettings.surfaceTheme)
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,18 +55,6 @@ 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,
@ -91,52 +79,12 @@ 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 ?: surfaceTheme.accentColor(isLight) val accent = accentColor ?: MaterialTheme.colorScheme.primary
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,
@ -151,21 +99,6 @@ 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,
@ -187,18 +120,6 @@ 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),
@ -207,7 +128,7 @@ fun baseUIColorScheme(
) )
} }
internal class SurfacePalette( private class SurfacePalette(
val backgroundTop: Color, val backgroundTop: Color,
val backgroundBottom: Color, val backgroundBottom: Color,
val surface: Color, val surface: Color,
@ -222,7 +143,7 @@ internal class SurfacePalette(
val outlineVariant: Color, val outlineVariant: Color,
) )
internal fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette { private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette {
return if (isLight) { return if (isLight) {
when (style) { when (style) {
SurfaceStyle.Glass -> SurfacePalette( SurfaceStyle.Glass -> SurfacePalette(
@ -256,18 +177,18 @@ internal fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalet
) )
SurfaceStyle.Watery -> SurfacePalette( SurfaceStyle.Watery -> SurfacePalette(
backgroundTop = Color(0xFFEAF7FE), backgroundTop = Color(0xFFD6F0FF),
backgroundBottom = Color(0xFFD8EFFB), backgroundBottom = Color(0xFFB4DFFA),
surface = Color(0xFFF0FAFF), surface = Color(0xFFF0FAFF),
surfaceVariant = Color(0xFFE2F2FB), surfaceVariant = Color(0xFFDCF0FC),
surfaceContainer = Color(0xFFEAF6FD), surfaceContainer = Color(0xFFE7F5FE),
surfaceContainerHigh = Color(0xFFF2FAFE), surfaceContainerHigh = Color(0xFFEFF9FF),
surfaceContainerHighest = Color(0xFFD8ECF8), surfaceContainerHighest = Color(0xFFCFE9F8),
onBackground = Color(0xFF08324A), onBackground = Color(0xFF08324A),
onSurface = Color(0xFF08324A), onSurface = Color(0xFF08324A),
onSurfaceVariant = Color(0xFF3A6884), onSurfaceVariant = Color(0xFF3A6884),
outline = Color(0xFF9EC4DA), outline = Color(0xFF8FBBD4),
outlineVariant = Color(0xFFD0E7F4), outlineVariant = Color(0xFFC6E2F2),
) )
SurfaceStyle.Icy -> SurfacePalette( SurfaceStyle.Icy -> SurfacePalette(
@ -333,8 +254,8 @@ internal fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalet
) )
SurfaceStyle.Watery -> SurfacePalette( SurfaceStyle.Watery -> SurfacePalette(
backgroundTop = Color(0xFF0A1622), backgroundTop = Color(0xFF06121E),
backgroundBottom = Color(0xFF0E1E2D), backgroundBottom = Color(0xFF0B1F31),
surface = Color(0xFF0E2233), surface = Color(0xFF0E2233),
surfaceVariant = Color(0xFF153148), surfaceVariant = Color(0xFF153148),
surfaceContainer = Color(0xFF0B1D2C), surfaceContainer = Color(0xFF0B1D2C),

View File

@ -138,27 +138,6 @@ 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,
@ -193,7 +172,6 @@ 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(
@ -202,7 +180,7 @@ fun OutlineButton(
indication = ripple(), indication = ripple(),
onClick = onClick, onClick = onClick,
) )
.then(if (hoverOnly && !isHovered) Modifier else Modifier.applyHighlight(state.colors)) .then(if (hoverOnly && !isHovered) Modifier else Modifier.highlight(state.colors.highlight))
.padding(state.padding), .padding(state.padding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -243,7 +221,6 @@ 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,
@ -251,7 +228,7 @@ fun PrimaryButton(
indication = ripple(), indication = ripple(),
onClick = onClick, onClick = onClick,
) )
.applyHighlight(state.colors) .highlight(state.colors.highlight)
.padding(state.padding), .padding(state.padding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -290,7 +267,6 @@ 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,
@ -298,7 +274,7 @@ fun SecondaryButton(
indication = ripple(), indication = ripple(),
onClick = onClick, onClick = onClick,
) )
.applyHighlight(state.colors) .highlight(state.colors.highlight)
.padding(state.padding), .padding(state.padding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {

View File

@ -75,7 +75,6 @@ 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,9 +146,8 @@ 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)
.applyHighlight(resolved.colors) .highlight(resolved.colors.highlight)
.then( .then(
if (onClick != null) { if (onClick != null) {
Modifier Modifier

View File

@ -123,7 +123,6 @@ 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,
@ -131,7 +130,7 @@ fun ChipTab(
indication = ripple(), indication = ripple(),
onClick = onClick, onClick = onClick,
) )
.applyHighlight(state.colors) .highlight(state.colors.highlight)
.padding(state.padding), .padding(state.padding),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {

View File

@ -39,14 +39,12 @@ import androidx.compose.ui.unit.dp
/** /**
* A theme-aware replacement for [Scaffold]. * A theme-aware replacement for [Scaffold].
* *
* A scaffold is always the current theme's **Primary Background** — there is no option to * Material's [Scaffold] paints Material's own surface colour, which hides the selected
* switch its surface. Material's [Scaffold] would paint Material's own surface colour and * [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass). [BaseScaffold] instead:
* hide the selected [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass), so [BaseScaffold]
* paints the theme's primary background material instead.
* *
* [containerColor] is a narrow escape hatch for the rare screen that paints its own * - is **transparent by default**, so the app-wide themed backdrop shows through, or
* backdrop (e.g. onboarding) and needs the scaffold to stay out of the way; it is not a * - paints a chosen [surface] material locally, or
* surface-style switch. * - uses an explicit [containerColor] when one is given.
* *
* 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].
@ -58,6 +56,7 @@ 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,
@ -71,9 +70,15 @@ fun BaseScaffold(
containerColor = Color.Transparent, containerColor = Color.Transparent,
contentWindowInsets = contentWindowInsets, contentWindowInsets = contentWindowInsets,
) { innerPadding -> ) { innerPadding ->
val colors = LocalBaseUIColors.current val backgroundModifier = when {
val backgroundModifier = containerColor?.let { Modifier.background(it) } containerColor != null -> Modifier.background(containerColor)
?: Modifier.baseSurfaceBackdrop(colors.surfaceTheme, colors.isLight) surface != null -> {
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)
} }
@ -85,7 +90,9 @@ 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,7 +35,6 @@ 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
@ -49,8 +48,7 @@ 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.drawscope.Stroke import androidx.compose.ui.graphics.luminance
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
@ -78,31 +76,7 @@ enum class SurfaceStyle {
} }
/** /**
* The role a surface plays within a theme. Each style produces a distinct material for * A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials.
* 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
@ -111,8 +85,7 @@ enum class SurfaceRole {
@Composable @Composable
fun BaseSurface( fun BaseSurface(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme, style: SurfaceStyle = SurfaceStyle.Glass,
role: SurfaceRole = SurfaceRole.PrimaryBackground,
shape: Shape = RectangleShape, shape: Shape = RectangleShape,
elevation: Dp? = null, elevation: Dp? = null,
contentColor: Color? = null, contentColor: Color? = null,
@ -120,60 +93,63 @@ fun BaseSurface(
contentAlignment: Alignment = Alignment.TopStart, contentAlignment: Alignment = Alignment.TopStart,
content: @Composable BoxScope.() -> Unit, content: @Composable BoxScope.() -> Unit,
) { ) {
val ambientScheme = LocalBaseUIColors.current val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
val isLight = ambientScheme.isLight val onSurface = MaterialTheme.colorScheme.onSurface
// 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 baseElevation = when (style) { val resolvedElevation = elevation ?: 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 resolvedElevation = elevation ?: when (role) { val resolvedContentColor = contentColor ?: when (style) {
SurfaceRole.PrimaryBackground -> baseElevation SurfaceStyle.Watery -> Color.White
SurfaceRole.SecondaryBackground -> baseElevation + 2.dp SurfaceStyle.Icy -> if (isLight) Color(0xFF0B3A55) else Color(0xFFDCEBF7)
SurfaceRole.PrimaryAccent, SurfaceRole.SecondaryAccent -> baseElevation + 6.dp SurfaceStyle.Chrome -> if (isLight) Color(0xFF1B2733) else Color(0xFFE7ECF2)
SurfaceRole.InverseBackground -> baseElevation SurfaceStyle.Acrylic, SurfaceStyle.Glass -> onSurface
SurfaceRole.InverseAction -> baseElevation + 6.dp
} }
val resolvedContentColor = contentColor ?: when (role) { val ambient = when (style) {
SurfaceRole.PrimaryBackground -> scheme.onBackgroundPrimary SurfaceStyle.Watery -> Color(0xFF0E6FB0).copy(alpha = 0.38f)
SurfaceRole.SecondaryBackground -> scheme.onBackgroundSecondary SurfaceStyle.Icy -> Color(0xFF3FA9E0).copy(alpha = 0.3f)
SurfaceRole.PrimaryAccent -> scheme.onAccentPrimary SurfaceStyle.Chrome -> Color.Black.copy(alpha = 0.3f)
SurfaceRole.SecondaryAccent -> scheme.onAccentSecondary SurfaceStyle.Acrylic -> onSurface.copy(alpha = 0.22f)
SurfaceRole.InverseBackground -> scheme.onBackgroundInverse SurfaceStyle.Glass -> onSurface.copy(alpha = 0.18f)
SurfaceRole.InverseAction -> scheme.onActionInverse
} }
val ambient = when (role) { val spot = ambient.copy(alpha = (ambient.alpha + 0.08f).coerceAtMost(1f))
SurfaceRole.PrimaryAccent -> scheme.accentPrimary.copy(alpha = 0.42f)
SurfaceRole.SecondaryAccent -> scheme.accentSecondary.copy(alpha = 0.42f) val borderBrush = when (style) {
SurfaceRole.InverseAction -> scheme.actionInverse.copy(alpha = 0.42f) SurfaceStyle.Watery -> Brush.verticalGradient(
SurfaceRole.InverseBackground -> Color.Black.copy(alpha = 0.3f) listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)),
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. Accent and secondary/inverse roles are filled, so they can cast one. // shape. Their depth comes from the edge, sheen and bevel instead.
val translucent = role == SurfaceRole.PrimaryBackground && val castsShadow = style != SurfaceStyle.Glass && style != SurfaceStyle.Acrylic
(style == SurfaceStyle.Glass || style == SurfaceStyle.Acrylic)
CompositionLocalProvider(LocalContentColor provides resolvedContentColor) { CompositionLocalProvider(LocalContentColor provides resolvedContentColor) {
Box( Box(
modifier = modifier modifier = modifier
.then( .then(
if (!translucent) { if (castsShadow) {
Modifier.shadow( Modifier.shadow(
elevation = resolvedElevation, elevation = resolvedElevation,
shape = shape, shape = shape,
@ -186,7 +162,7 @@ fun BaseSurface(
) )
.clip(shape) .clip(shape)
.drawWithCache { .drawWithCache {
onDrawBehind { drawSurfaceMaterial(style, role, isLight, scheme) } onDrawBehind { drawSurfaceMaterial(style, isLight) }
} }
.border(BorderStroke(1.dp, borderBrush), shape) .border(BorderStroke(1.dp, borderBrush), shape)
.padding(contentPadding), .padding(contentPadding),
@ -197,313 +173,199 @@ fun BaseSurface(
} }
/** /**
* Paints the full-bleed material of [style] as a page background: the primary material * Paints the full-bleed material of [style] as a page background: the material body plus
* body plus its sheen and reflections, but no panel bevel (a backdrop has no edges). * its own 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 { onDrawBehind { drawSurfaceBase(style, isLight) }
drawStyleBase(style, isLight)
drawStyleDecorations(style, isLight)
}
} }
/** private fun DrawScope.drawSurfaceMaterial(style: SurfaceStyle, isLight: Boolean) {
* Overlays a [SurfaceStyle]'s material character (gloss, bubbles, facets, specular band, drawSurfaceBase(style, isLight)
* grain) and its inner bevel on a node that already carries a base fill. Compact components drawInnerBevel(isLight)
* — 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)
}
} }
/** Ambient-scheme overload of [baseSurfaceTexture] for a single role. */ private fun DrawScope.drawSurfaceBase(style: SurfaceStyle, isLight: Boolean) {
@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) { when (style) {
SurfaceStyle.Glass -> { SurfaceStyle.Watery -> drawWatery(isLight)
val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6) SurfaceStyle.Icy -> drawIcy(isLight)
drawRect( SurfaceStyle.Chrome -> drawChrome(isLight)
brush = Brush.verticalGradient( SurfaceStyle.Acrylic -> drawAcrylic(isLight)
listOf( SurfaceStyle.Glass -> drawGlass(isLight)
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.drawGlass(isLight: Boolean) {
private fun DrawScope.drawAccentBase(accent: Color) { // 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( drawRect(
brush = Brush.verticalGradient( brush = Brush.verticalGradient(
listOf( listOf(
lerp(accent, Color.White, 0.45f), tint.copy(alpha = if (isLight) 0.4f else 0.2f),
accent, tint.copy(alpha = if (isLight) 0.24f else 0.1f),
lerp(accent, Color.Black, 0.16f),
), ),
), ),
) )
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,
),
)
} }
// --- Material decorations (one per style) ------------------------------------ private fun DrawScope.drawIcy(isLight: Boolean) {
val colors = if (isLight) {
private fun DrawScope.drawStyleDecorations(style: SurfaceStyle, isLight: Boolean) { listOf(Color(0xFFF4FCFF), Color(0xFFCBE9FB), Color(0xFF9BD3F0))
when (style) { } else {
SurfaceStyle.Glass -> { // Snowy-night ice: dim slate-blue with faint frost.
drawGloss(if (isLight) 0.55f else 0.16f) listOf(Color(0xFF2A3B4D), Color(0xFF1B2A39), Color(0xFF0F1A26))
drawBottomReflection(if (isLight) 0.4f else 0.1f)
drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f)
}
SurfaceStyle.Acrylic -> {
// Fine grain so the panel reads as a translucent acrylic sheet.
val random = Random(23)
repeat(180) {
val light = random.nextBoolean()
drawCircle(
color = if (light) {
Color.White.copy(alpha = if (isLight) 0.16f else 0.06f)
} else {
Color.Black.copy(alpha = if (isLight) 0.06f else 0.08f)
},
radius = 0.7f,
center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height),
)
}
drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f)
}
SurfaceStyle.Chrome -> {
// Hard specular reflection near the top.
drawRect(
brush = Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = if (isLight) 0.85f else 0.18f),
Color.Transparent,
),
startY = size.height * 0.05f,
endY = size.height * 0.22f,
),
)
// Darkened lower edge for the metal bevel.
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.25f)),
startY = size.height * 0.78f,
endY = size.height,
),
)
}
SurfaceStyle.Icy -> {
val frost = if (isLight) Color.White else Color(0xFFB8CCE0)
// Crystalline facets (kept inset so they are never clipped by the shape).
val random = Random(37)
repeat(4) {
val s = size.minDimension * (0.2f + random.nextFloat() * 0.3f)
val x = random.nextFloat() * (size.width - s).coerceAtLeast(0f)
val y = random.nextFloat() * (size.height - s).coerceAtLeast(0f)
val path = Path().apply {
moveTo(x, y)
lineTo(x + s, y + s * 0.4f)
lineTo(x + s * 0.4f, y + s)
close()
}
drawPath(path, frost.copy(alpha = if (isLight) 0.25f else 0.12f))
}
// Frost speckles.
repeat(90) {
drawCircle(
color = frost.copy(alpha = if (isLight) 0.5f else 0.28f),
radius = 0.8f,
center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height),
)
}
drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f)
}
SurfaceStyle.Watery -> {
val sheen = if (isLight) Color.White else Color(0xFFAFD3F2)
// Caustic light pooling from the surface.
drawRect(
brush = Brush.verticalGradient(
colors = listOf(
sheen.copy(alpha = if (isLight) 0.6f else 0.2f),
Color.Transparent,
),
startY = 0f,
endY = size.height * 0.55f,
),
)
val random = Random(53)
// Refracted caustic blobs — the bright, rippling patterns of moving water.
repeat(9) {
val rx = size.minDimension * (0.14f + random.nextFloat() * 0.26f)
val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f)
val cy = rx + random.nextFloat() * (size.height - 2 * rx).coerceAtLeast(0f)
drawOval(
brush = Brush.radialGradient(
colors = listOf(
sheen.copy(alpha = if (isLight) 0.5f else 0.22f),
Color.Transparent,
),
center = Offset(cx, cy),
radius = rx,
),
topLeft = Offset(cx - rx, cy - rx * 0.7f),
size = Size(rx * 2f, rx * 1.4f),
)
}
// Ripple crests: thin horizontal highlights that read as surface waves.
repeat(3) {
val y = size.height * (0.18f + random.nextFloat() * 0.64f)
drawRect(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
sheen.copy(alpha = if (isLight) 0.4f else 0.16f),
Color.Transparent,
),
startY = y - size.height * 0.035f,
endY = y + size.height * 0.035f,
),
)
}
drawBubbles(seed = 71, count = 11, alpha = if (isLight) 0.6f else 0.3f, rim = true)
}
} }
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) {
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)
}
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)
// Caustic / moonlight pooling from the surface.
drawRect(
brush = Brush.verticalGradient(
colors = listOf(sheen.copy(alpha = if (isLight) 0.35f else 0.22f), Color.Transparent),
startY = 0f,
endY = size.height * 0.5f,
),
)
val random = Random(53)
repeat(5) {
val rx = size.width * (0.12f + random.nextFloat() * 0.2f)
val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f)
val cy = rx * 0.5f + random.nextFloat() * (size.height * 0.6f - rx).coerceAtLeast(0f)
drawOval(
brush = Brush.radialGradient(
colors = listOf(sheen.copy(alpha = if (isLight) 0.3f else 0.18f), Color.Transparent),
center = Offset(cx, cy),
radius = rx,
),
topLeft = Offset(cx - rx, cy - rx * 0.5f),
size = Size(rx * 2f, rx),
)
}
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) {
@ -544,7 +406,7 @@ private fun DrawScope.drawInnerBevel(isLight: Boolean) {
) )
} }
private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float, rim: Boolean = false) { private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float) {
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)
@ -561,97 +423,59 @@ private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float, rim: Bool
radius = radius, radius = radius,
center = center, center = center,
) )
if (rim) {
drawCircle(
color = Color.White.copy(alpha = alpha * 0.7f),
radius = radius,
center = center,
style = Stroke(width = (radius * 0.14f).coerceAtLeast(0.8f)),
)
}
} }
} }
private fun styleBorderBrush(style: SurfaceStyle): Brush = when (style) { @BaseUIPhonePreview
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 @BaseUIDesktopPreview
@Composable @Composable
private fun SurfacePreview() { private fun BaseSurfacePreview() {
BaseUIPreview { BaseUIPreview {
val colors = LocalBaseUIColors.current
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(20.dp), .padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
SurfaceStyle.entries.forEach { style -> 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
@Composable
private fun BaseSurfaceGridPreview() {
BaseUIPreview {
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
SurfaceStyle.entries.chunked(2).forEach { rowStyles ->
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
SurfaceRole.entries.forEach { role -> rowStyles.forEach { style ->
BaseSurface( BaseSurface(
style = style, style = style,
role = role,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(84.dp), .height(140.dp),
contentPadding = PaddingValues(10.dp),
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -659,41 +483,54 @@ private fun SurfacePreview() {
) { ) {
Text( Text(
text = style.name, text = style.name,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Text( Text(
text = role.name, text = "Frutiger Aero",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelMedium,
) )
} }
} }
} }
} 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,13 +124,6 @@ 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(
@ -139,8 +132,6 @@ 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(
@ -241,12 +232,16 @@ 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.
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Primary surface (subtle) with the style's palette, and the secondary surface val glassTop = if (isLight) {
// (slightly accented) used by cards and grouped content. Color.White
val panelTop = lerp(colors.backgroundPrimary, Color.White, if (isLight) 0.45f else 0.06f) } else {
val panelBottom = colors.surfaceVariant lerp(colors.surfaceContainerHigh, Color.White, 0.10f)
val secondaryTop = lerp(colors.backgroundSecondary, Color.White, if (isLight) 0.35f else 0.05f) }
val secondaryBottom = colors.backgroundSecondary val glassBottom = if (isLight) {
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 {
@ -297,12 +292,12 @@ fun rememberBaseUITheme(
// --- Glass (outline) ------------------------------------------------- // --- Glass (outline) -------------------------------------------------
val outlineColors = BaseUITheme.InteractionState( val outlineColors = BaseUITheme.InteractionState(
normal = BaseUITheme.ButtonColors( normal = BaseUITheme.ButtonColors(
background = glassBrush(panelTop, panelBottom), background = glassBrush(glassTop, glassBottom),
foreground = colors.onSurface, foreground = colors.onSurface,
highlight = gloss, highlight = gloss,
), ),
hovered = BaseUITheme.ButtonColors( hovered = BaseUITheme.ButtonColors(
background = glassBrush(panelTop, panelBottom), background = glassBrush(glassTop, glassBottom),
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),
), ),
@ -312,11 +307,11 @@ fun rememberBaseUITheme(
highlight = glossSoft, highlight = glossSoft,
), ),
focused = BaseUITheme.ButtonColors( focused = BaseUITheme.ButtonColors(
background = glassBrush(panelTop, panelBottom), background = glassBrush(glassTop, glassBottom),
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,
@ -353,10 +348,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.accentPrimary), colors.onAccentPrimary, gloss), normal = BaseUITheme.ButtonColors(aqua(colors.primary), colors.onPrimary, gloss),
hovered = BaseUITheme.ButtonColors( hovered = BaseUITheme.ButtonColors(
aqua(colors.accentPrimary), aqua(colors.primary),
colors.onAccentPrimary, colors.onPrimary,
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(
@ -364,11 +359,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.onAccentPrimary, colors.onPrimary,
glossSoft, glossSoft,
), ),
focused = BaseUITheme.ButtonColors(aqua(colors.accentPrimary), colors.onAccentPrimary, gloss), focused = BaseUITheme.ButtonColors(aqua(colors.primary), colors.onPrimary, gloss),
).withRole(SurfaceRole.PrimaryAccent) )
val primaryShadow = BaseUITheme.InteractionState( val primaryShadow = BaseUITheme.InteractionState(
normal = BaseUITheme.Shadow( normal = BaseUITheme.Shadow(
10.dp, 10.dp,
@ -403,7 +398,7 @@ fun rememberBaseUITheme(
) )
// --- Secondary (soft aqua glass) ------------------------------------- // --- Secondary (soft aqua glass) -------------------------------------
val secondaryBase = colors.backgroundSecondary val secondaryBase = colors.secondaryContainer
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),
@ -428,7 +423,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,
@ -597,10 +592,10 @@ fun rememberBaseUITheme(
), ),
textField = TextFieldTheme( textField = TextFieldTheme(
background = BaseUITheme.InteractionState( background = BaseUITheme.InteractionState(
normal = glassBrush(panelTop, panelBottom), normal = glassBrush(glassTop, glassBottom),
hovered = glassBrush(panelTop, panelBottom), hovered = glassBrush(glassTop, glassBottom),
pressed = glassBrush(glassPressed, glassPressed), pressed = glassBrush(glassPressed, glassPressed),
focused = glassBrush(panelTop, panelBottom), focused = glassBrush(glassTop, glassBottom),
), ),
highlight = BaseUITheme.InteractionState( highlight = BaseUITheme.InteractionState(
normal = gloss, normal = gloss,
@ -652,7 +647,7 @@ fun rememberBaseUITheme(
foreground = textFieldForeground, foreground = textFieldForeground,
), ),
autoCompleteMenuTheme = AutoCompleteMenuTheme( autoCompleteMenuTheme = AutoCompleteMenuTheme(
background = glassBrush(panelTop, panelBottom), background = glassBrush(glassTop, glassBottom),
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),
@ -721,7 +716,7 @@ fun rememberBaseUITheme(
border = glassBorderState, border = glassBorderState,
padding = noPadding, padding = noPadding,
), ),
checkmarkColor = colors.onAccentPrimary, checkmarkColor = colors.onPrimary,
), ),
chipTab = ChipTabTheme( chipTab = ChipTabTheme(
selected = BaseUITheme.ButtonStyle( selected = BaseUITheme.ButtonStyle(
@ -788,7 +783,7 @@ fun rememberBaseUITheme(
), ),
), ),
card = BaseUITheme.CardTheme( card = BaseUITheme.CardTheme(
background = glassBrush(secondaryTop, secondaryBottom), background = glassBrush(glassTop, glassBottom),
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),
@ -798,18 +793,20 @@ 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(panelTop, panelBottom), hovered = glassBrush(glassTop, glassBottom),
pressed = glassBrush(glassPressed, glassPressed), pressed = glassBrush(glassPressed, glassPressed),
focused = glassBrush(panelTop, panelBottom), focused = glassBrush(glassTop, glassBottom),
selected = glassBrush(secondaryTop, secondaryBottom), selected = glassBrush(
lerp(colors.primaryContainer, Color.White, 0.4f),
colors.primaryContainer,
),
disabled = glassBrush( disabled = glassBrush(
panelTop.copy(alpha = 0.5f), glassTop.copy(alpha = 0.5f),
panelBottom.copy(alpha = 0.5f), glassBottom.copy(alpha = 0.5f),
), ),
), ),
shape = BaseUITheme.AdvancedInteractionState( shape = BaseUITheme.AdvancedInteractionState(
@ -860,7 +857,7 @@ fun rememberBaseUITheme(
), ),
), ),
dialog = BaseUITheme.DialogTheme( dialog = BaseUITheme.DialogTheme(
background = glassBrush(panelTop, panelBottom), background = glassBrush(glassTop, glassBottom),
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),
@ -873,7 +870,7 @@ fun rememberBaseUITheme(
padding = PaddingValues(24.dp), padding = PaddingValues(24.dp),
), ),
dropdownMenu = BaseUITheme.DropdownMenuTheme( dropdownMenu = BaseUITheme.DropdownMenuTheme(
background = glassBrush(panelTop, panelBottom), background = glassBrush(glassTop, glassBottom),
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(
@ -882,7 +879,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(panelTop, panelBottom), itemHoverBackground = glassBrush(glassTop, glassBottom),
itemForeground = colors.onSurface, itemForeground = colors.onSurface,
itemHighlight = gloss, itemHighlight = gloss,
padding = PaddingValues(6.dp), padding = PaddingValues(6.dp),
@ -908,16 +905,6 @@ 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,9 +184,8 @@ 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)
.applyHighlight(resolved.colors) .highlight(resolved.colors.highlight)
.then( .then(
if (onCheckedChange != null) { if (onCheckedChange != null) {
Modifier Modifier

View File

@ -18,25 +18,13 @@
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,
@ -47,7 +35,6 @@ 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()
@ -55,56 +42,15 @@ fun SpotubeTheme(
settings.accentColor.toLightColor() settings.accentColor.toLightColor()
} }
val baseColors = remember(settings.surfaceTheme, isLight, accent) { val colorScheme = rememberDynamicColorScheme(
baseUIColorScheme(settings.surfaceTheme, isLight, accent)
}
val dynamicScheme = rememberDynamicColorScheme(
seedColor = accent, seedColor = accent,
isDark = isDarkTheme, isDark = isDarkTheme,
) )
MaterialTheme( MaterialTheme(
colorScheme = dynamicScheme.mergeWithBaseColors(baseColors), colorScheme = colorScheme,
) { ) {
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

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

View File

@ -36,6 +36,7 @@ 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
@ -69,10 +70,8 @@ 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
// //
@ -116,12 +115,14 @@ fun AppFloatingPlayer(
} }
} }
BaseSurface( Surface(
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,10 +72,8 @@ 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
@ -212,9 +210,7 @@ fun AppLargePlayer(
} }
} }
// A translucent tint (not near-opaque) so the blurred art behind still reads through, and val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
// 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
@ -238,7 +234,6 @@ fun AppLargePlayer(
) )
} }
} }
.baseSurfaceTexture(SurfaceRole.PrimaryBackground)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier

View File

@ -55,8 +55,6 @@ 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
@ -90,8 +88,7 @@ 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)) {