mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(ui): new custom surface integration on all components
This commit is contained in:
parent
9f96b07ebd
commit
963b360a81
@ -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 & 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 & 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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
) {
|
||||
|
||||
@ -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),
|
||||
) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
) {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -35,6 +35,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -48,7 +49,8 @@ import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
@ -76,7 +78,31 @@ enum class SurfaceStyle {
|
||||
}
|
||||
|
||||
/**
|
||||
* A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials.
|
||||
* The role a surface plays within a theme. Each style produces a distinct material for
|
||||
* every role, so a theme is composed from them:
|
||||
*
|
||||
* - [PrimaryBackground] — the subtle main background (pages, panels).
|
||||
* - [SecondaryBackground] — a distinct, complementary background hue that pairs with the
|
||||
* primary (grouped/alternate sections).
|
||||
* - [PrimaryAccent] — the primary accent surface for interactive elements (buttons,
|
||||
* toggles, active chips).
|
||||
* - [SecondaryAccent] — a complementary accent surface (secondary actions, highlights).
|
||||
* - [InverseBackground] — the inverted-brightness background (tooltips, snackbars).
|
||||
* - [InverseAction] — the inverted-brightness action surface.
|
||||
*/
|
||||
@Serializable
|
||||
enum class SurfaceRole {
|
||||
PrimaryBackground,
|
||||
SecondaryBackground,
|
||||
PrimaryAccent,
|
||||
SecondaryAccent,
|
||||
InverseBackground,
|
||||
InverseAction,
|
||||
}
|
||||
|
||||
/**
|
||||
* A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials,
|
||||
* in the given [SurfaceRole].
|
||||
*
|
||||
* Distinct from [androidx.compose.material3.Surface] — this draws decorative material
|
||||
* backgrounds (gradients, gloss, grain, bubbles, facets) and provides a matching
|
||||
@ -85,7 +111,8 @@ enum class SurfaceStyle {
|
||||
@Composable
|
||||
fun BaseSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
style: SurfaceStyle = SurfaceStyle.Glass,
|
||||
style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme,
|
||||
role: SurfaceRole = SurfaceRole.PrimaryBackground,
|
||||
shape: Shape = RectangleShape,
|
||||
elevation: Dp? = null,
|
||||
contentColor: Color? = null,
|
||||
@ -93,63 +120,60 @@ fun BaseSurface(
|
||||
contentAlignment: Alignment = Alignment.TopStart,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
|
||||
val onSurface = MaterialTheme.colorScheme.onSurface
|
||||
val ambientScheme = LocalBaseUIColors.current
|
||||
val isLight = ambientScheme.isLight
|
||||
// A surface is rendered in its own style's colour scheme, so previewing a style shows
|
||||
// that style's real accent instead of the ambient theme's. When the requested style is
|
||||
// the ambient theme, the ambient scheme is used so a user-chosen accent still applies.
|
||||
val ownScheme = remember(style, isLight) {
|
||||
baseUIColorScheme(style, isLight, style.accentColor(isLight))
|
||||
}
|
||||
val scheme = if (ambientScheme.surfaceTheme == style) ambientScheme else ownScheme
|
||||
val palette = surfacePalette(style, isLight)
|
||||
|
||||
val resolvedElevation = elevation ?: when (style) {
|
||||
val baseElevation = when (style) {
|
||||
SurfaceStyle.Watery -> 14.dp
|
||||
SurfaceStyle.Icy -> 12.dp
|
||||
SurfaceStyle.Chrome -> 10.dp
|
||||
SurfaceStyle.Acrylic -> 16.dp
|
||||
SurfaceStyle.Glass -> 12.dp
|
||||
}
|
||||
val resolvedContentColor = contentColor ?: when (style) {
|
||||
SurfaceStyle.Watery -> Color.White
|
||||
SurfaceStyle.Icy -> if (isLight) Color(0xFF0B3A55) else Color(0xFFDCEBF7)
|
||||
SurfaceStyle.Chrome -> if (isLight) Color(0xFF1B2733) else Color(0xFFE7ECF2)
|
||||
SurfaceStyle.Acrylic, SurfaceStyle.Glass -> onSurface
|
||||
val resolvedElevation = elevation ?: when (role) {
|
||||
SurfaceRole.PrimaryBackground -> baseElevation
|
||||
SurfaceRole.SecondaryBackground -> baseElevation + 2.dp
|
||||
SurfaceRole.PrimaryAccent, SurfaceRole.SecondaryAccent -> baseElevation + 6.dp
|
||||
SurfaceRole.InverseBackground -> baseElevation
|
||||
SurfaceRole.InverseAction -> baseElevation + 6.dp
|
||||
}
|
||||
val ambient = when (style) {
|
||||
SurfaceStyle.Watery -> Color(0xFF0E6FB0).copy(alpha = 0.38f)
|
||||
SurfaceStyle.Icy -> Color(0xFF3FA9E0).copy(alpha = 0.3f)
|
||||
SurfaceStyle.Chrome -> Color.Black.copy(alpha = 0.3f)
|
||||
SurfaceStyle.Acrylic -> onSurface.copy(alpha = 0.22f)
|
||||
SurfaceStyle.Glass -> onSurface.copy(alpha = 0.18f)
|
||||
val resolvedContentColor = contentColor ?: when (role) {
|
||||
SurfaceRole.PrimaryBackground -> scheme.onBackgroundPrimary
|
||||
SurfaceRole.SecondaryBackground -> scheme.onBackgroundSecondary
|
||||
SurfaceRole.PrimaryAccent -> scheme.onAccentPrimary
|
||||
SurfaceRole.SecondaryAccent -> scheme.onAccentSecondary
|
||||
SurfaceRole.InverseBackground -> scheme.onBackgroundInverse
|
||||
SurfaceRole.InverseAction -> scheme.onActionInverse
|
||||
}
|
||||
val spot = ambient.copy(alpha = (ambient.alpha + 0.08f).coerceAtMost(1f))
|
||||
|
||||
val borderBrush = when (style) {
|
||||
SurfaceStyle.Watery -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)),
|
||||
)
|
||||
|
||||
SurfaceStyle.Icy -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.95f), Color.White.copy(alpha = 0.35f)),
|
||||
)
|
||||
|
||||
SurfaceStyle.Chrome -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.95f), Color.Black.copy(alpha = 0.28f)),
|
||||
)
|
||||
|
||||
SurfaceStyle.Acrylic -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.12f)),
|
||||
)
|
||||
|
||||
SurfaceStyle.Glass -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.9f), Color.White.copy(alpha = 0.3f)),
|
||||
)
|
||||
val ambient = when (role) {
|
||||
SurfaceRole.PrimaryAccent -> scheme.accentPrimary.copy(alpha = 0.42f)
|
||||
SurfaceRole.SecondaryAccent -> scheme.accentSecondary.copy(alpha = 0.42f)
|
||||
SurfaceRole.InverseAction -> scheme.actionInverse.copy(alpha = 0.42f)
|
||||
SurfaceRole.InverseBackground -> Color.Black.copy(alpha = 0.3f)
|
||||
else -> palette.onSurface.copy(alpha = 0.2f)
|
||||
}
|
||||
val spot = ambient.copy(alpha = (ambient.alpha + 0.07f).coerceAtMost(1f))
|
||||
val borderBrush = roleBorderBrush(style, role, isLight, scheme)
|
||||
|
||||
// Translucent materials (Glass, Acrylic) must not use a drop shadow: it is drawn
|
||||
// behind the node and, because the panel is see-through, shows up as a dark inset
|
||||
// shape. Their depth comes from the edge, sheen and bevel instead.
|
||||
val castsShadow = style != SurfaceStyle.Glass && style != SurfaceStyle.Acrylic
|
||||
// shape. Accent and secondary/inverse roles are filled, so they can cast one.
|
||||
val translucent = role == SurfaceRole.PrimaryBackground &&
|
||||
(style == SurfaceStyle.Glass || style == SurfaceStyle.Acrylic)
|
||||
|
||||
CompositionLocalProvider(LocalContentColor provides resolvedContentColor) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (castsShadow) {
|
||||
if (!translucent) {
|
||||
Modifier.shadow(
|
||||
elevation = resolvedElevation,
|
||||
shape = shape,
|
||||
@ -162,7 +186,7 @@ fun BaseSurface(
|
||||
)
|
||||
.clip(shape)
|
||||
.drawWithCache {
|
||||
onDrawBehind { drawSurfaceMaterial(style, isLight) }
|
||||
onDrawBehind { drawSurfaceMaterial(style, role, isLight, scheme) }
|
||||
}
|
||||
.border(BorderStroke(1.dp, borderBrush), shape)
|
||||
.padding(contentPadding),
|
||||
@ -173,199 +197,313 @@ fun BaseSurface(
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints the full-bleed material of [style] as a page background: the material body plus
|
||||
* its own sheen and reflections, but no panel bevel (a backdrop has no edges).
|
||||
* Paints the full-bleed material of [style] as a page background: the primary material
|
||||
* body plus its sheen and reflections, but no panel bevel (a backdrop has no edges).
|
||||
*/
|
||||
fun Modifier.baseSurfaceBackdrop(style: SurfaceStyle, isLight: Boolean): Modifier =
|
||||
drawWithCache {
|
||||
onDrawBehind { drawSurfaceBase(style, isLight) }
|
||||
}
|
||||
|
||||
private fun DrawScope.drawSurfaceMaterial(style: SurfaceStyle, isLight: Boolean) {
|
||||
drawSurfaceBase(style, isLight)
|
||||
drawInnerBevel(isLight)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawSurfaceBase(style: SurfaceStyle, isLight: Boolean) {
|
||||
when (style) {
|
||||
SurfaceStyle.Watery -> drawWatery(isLight)
|
||||
SurfaceStyle.Icy -> drawIcy(isLight)
|
||||
SurfaceStyle.Chrome -> drawChrome(isLight)
|
||||
SurfaceStyle.Acrylic -> drawAcrylic(isLight)
|
||||
SurfaceStyle.Glass -> drawGlass(isLight)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawGlass(isLight: Boolean) {
|
||||
// See-through glass: a faint blue-tinted fill so whatever is behind shows through,
|
||||
// defined by its bright edge, sheen and shadow rather than an opaque body.
|
||||
val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6)
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
listOf(
|
||||
tint.copy(alpha = if (isLight) 0.4f else 0.2f),
|
||||
tint.copy(alpha = if (isLight) 0.24f else 0.1f),
|
||||
),
|
||||
),
|
||||
)
|
||||
drawGloss(if (isLight) 0.55f else 0.16f)
|
||||
drawBottomReflection(if (isLight) 0.4f else 0.1f)
|
||||
drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawAcrylic(isLight: Boolean) {
|
||||
// See-through acrylic: a translucent tinted sheet with a fine grain.
|
||||
val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8)
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
listOf(
|
||||
tint.copy(alpha = if (isLight) 0.45f else 0.22f),
|
||||
tint.copy(alpha = if (isLight) 0.28f else 0.12f),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Fine grain so the panel reads as a translucent acrylic sheet.
|
||||
val random = Random(23)
|
||||
repeat(180) {
|
||||
val light = random.nextBoolean()
|
||||
drawCircle(
|
||||
color = if (light) {
|
||||
Color.White.copy(alpha = if (isLight) 0.16f else 0.06f)
|
||||
} else {
|
||||
Color.Black.copy(alpha = if (isLight) 0.06f else 0.08f)
|
||||
},
|
||||
radius = 0.7f,
|
||||
center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height),
|
||||
)
|
||||
}
|
||||
drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawChrome(isLight: Boolean) {
|
||||
if (isLight) {
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
0f to Color(0xFFF8FBFF),
|
||||
0.16f to Color(0xFFD6DEE7),
|
||||
0.4f to Color(0xFF8E99A8),
|
||||
0.5f to Color(0xFFEDF2F7),
|
||||
0.62f to Color(0xFF9AA5B4),
|
||||
1f to Color(0xFF6C7787),
|
||||
),
|
||||
)
|
||||
// Hard specular reflection near the top.
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.White.copy(alpha = 0.85f), Color.Transparent),
|
||||
startY = size.height * 0.05f,
|
||||
endY = size.height * 0.22f,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// Dark gunmetal for the night variant.
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
0f to Color(0xFF3B424C),
|
||||
0.16f to Color(0xFF2A303A),
|
||||
0.4f to Color(0xFF1B2028),
|
||||
0.5f to Color(0xFF343B45),
|
||||
0.62f to Color(0xFF232830),
|
||||
1f to Color(0xFF141820),
|
||||
),
|
||||
)
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.White.copy(alpha = 0.18f), Color.Transparent),
|
||||
startY = size.height * 0.05f,
|
||||
endY = size.height * 0.22f,
|
||||
),
|
||||
)
|
||||
}
|
||||
// Darkened lower edge for the metal bevel.
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.25f)),
|
||||
startY = size.height * 0.78f,
|
||||
endY = size.height,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawIcy(isLight: Boolean) {
|
||||
val colors = if (isLight) {
|
||||
listOf(Color(0xFFF4FCFF), Color(0xFFCBE9FB), Color(0xFF9BD3F0))
|
||||
} else {
|
||||
// Snowy-night ice: dim slate-blue with faint frost.
|
||||
listOf(Color(0xFF2A3B4D), Color(0xFF1B2A39), Color(0xFF0F1A26))
|
||||
}
|
||||
drawRect(brush = Brush.verticalGradient(colors))
|
||||
|
||||
val frost = if (isLight) Color.White else Color(0xFFB8CCE0)
|
||||
|
||||
// Crystalline facets (kept inset so they are never clipped by the shape).
|
||||
val random = Random(37)
|
||||
repeat(4) {
|
||||
val s = size.minDimension * (0.2f + random.nextFloat() * 0.3f)
|
||||
val x = random.nextFloat() * (size.width - s).coerceAtLeast(0f)
|
||||
val y = random.nextFloat() * (size.height - s).coerceAtLeast(0f)
|
||||
val path = Path().apply {
|
||||
moveTo(x, y)
|
||||
lineTo(x + s, y + s * 0.4f)
|
||||
lineTo(x + s * 0.4f, y + s)
|
||||
close()
|
||||
onDrawBehind {
|
||||
drawStyleBase(style, isLight)
|
||||
drawStyleDecorations(style, isLight)
|
||||
}
|
||||
drawPath(path, frost.copy(alpha = if (isLight) 0.25f else 0.12f))
|
||||
}
|
||||
|
||||
// Frost speckles.
|
||||
repeat(90) {
|
||||
drawCircle(
|
||||
color = frost.copy(alpha = if (isLight) 0.5f else 0.28f),
|
||||
radius = 0.8f,
|
||||
center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height),
|
||||
)
|
||||
/**
|
||||
* Overlays a [SurfaceStyle]'s material character (gloss, bubbles, facets, specular band,
|
||||
* grain) and its inner bevel on a node that already carries a base fill. Compact components
|
||||
* — buttons, toggles, chips — use this to share the exact look of a [BaseSurface] in [role]
|
||||
* without redrawing the fill (which comes from their own interaction-state brush).
|
||||
*/
|
||||
fun Modifier.baseSurfaceTexture(
|
||||
style: SurfaceStyle,
|
||||
role: SurfaceRole,
|
||||
isLight: Boolean,
|
||||
): Modifier = drawWithCache {
|
||||
onDrawBehind {
|
||||
val effectiveLight = when (role) {
|
||||
SurfaceRole.InverseBackground, SurfaceRole.InverseAction -> !isLight
|
||||
else -> isLight
|
||||
}
|
||||
drawStyleDecorations(style, effectiveLight)
|
||||
drawInnerBevel(effectiveLight)
|
||||
}
|
||||
drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawWatery(isLight: Boolean) {
|
||||
val colors = if (isLight) {
|
||||
listOf(Color(0xFFA6E3FF), Color(0xFF3FB6EA), Color(0xFF0E6FB0))
|
||||
} else {
|
||||
// Moonlit night water: deep blue with a pale silver sheen.
|
||||
listOf(Color(0xFF1C3A5E), Color(0xFF102942), Color(0xFF071827))
|
||||
/** Ambient-scheme overload of [baseSurfaceTexture] for a single role. */
|
||||
@Composable
|
||||
fun Modifier.baseSurfaceTexture(role: SurfaceRole): Modifier {
|
||||
val scheme = LocalBaseUIColors.current
|
||||
return baseSurfaceTexture(scheme.surfaceTheme, role, scheme.isLight)
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints the full [SurfaceStyle] material for [role] — base fill, material character and inner
|
||||
* bevel — using the ambient [BaseUIColorScheme]. Use it to make a chrome panel (a sidebar, a
|
||||
* player bar) a first-class surface rather than a flat Material surface.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.baseSurfaceMaterial(
|
||||
role: SurfaceRole = SurfaceRole.PrimaryBackground,
|
||||
style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme,
|
||||
): Modifier {
|
||||
val scheme = LocalBaseUIColors.current
|
||||
return drawWithCache {
|
||||
onDrawBehind { drawSurfaceMaterial(style, role, scheme.isLight, scheme) }
|
||||
}
|
||||
drawRect(brush = Brush.verticalGradient(colors))
|
||||
}
|
||||
|
||||
val sheen = if (isLight) Color.White else Color(0xFFB9D2F0)
|
||||
private fun DrawScope.drawSurfaceMaterial(
|
||||
style: SurfaceStyle,
|
||||
role: SurfaceRole,
|
||||
isLight: Boolean,
|
||||
scheme: BaseUIColorScheme,
|
||||
) {
|
||||
// Role base fill.
|
||||
when (role) {
|
||||
SurfaceRole.PrimaryBackground -> drawStyleBase(style, isLight)
|
||||
|
||||
// Caustic / moonlight pooling from the surface.
|
||||
// A distinct, complementary background hue (keeps the style's texture below).
|
||||
SurfaceRole.SecondaryBackground -> drawRect(color = scheme.backgroundSecondary)
|
||||
|
||||
SurfaceRole.PrimaryAccent -> drawAccentBase(scheme.accentPrimary)
|
||||
SurfaceRole.SecondaryAccent -> drawAccentBase(scheme.accentSecondary)
|
||||
|
||||
// Opaque inverted background so translucent styles still read as a solid surface.
|
||||
SurfaceRole.InverseBackground -> drawRect(color = scheme.backgroundInverse)
|
||||
|
||||
SurfaceRole.InverseAction -> drawAccentBase(scheme.actionInverse)
|
||||
}
|
||||
|
||||
// The material's own character (bubbles, facets, grain, specular band, sheen).
|
||||
val effectiveLight = when (role) {
|
||||
SurfaceRole.InverseBackground, SurfaceRole.InverseAction -> !isLight
|
||||
else -> isLight
|
||||
}
|
||||
drawStyleDecorations(style, effectiveLight)
|
||||
drawInnerBevel(effectiveLight)
|
||||
}
|
||||
|
||||
// --- Material base gradients (one per style) ---------------------------------
|
||||
|
||||
private fun DrawScope.drawStyleBase(style: SurfaceStyle, isLight: Boolean) {
|
||||
when (style) {
|
||||
SurfaceStyle.Glass -> {
|
||||
val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6)
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
listOf(
|
||||
tint.copy(alpha = if (isLight) 0.4f else 0.2f),
|
||||
tint.copy(alpha = if (isLight) 0.24f else 0.1f),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
SurfaceStyle.Acrylic -> {
|
||||
val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8)
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
listOf(
|
||||
tint.copy(alpha = if (isLight) 0.45f else 0.22f),
|
||||
tint.copy(alpha = if (isLight) 0.28f else 0.12f),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
SurfaceStyle.Chrome -> {
|
||||
if (isLight) {
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
0f to Color(0xFFF8FBFF),
|
||||
0.16f to Color(0xFFD6DEE7),
|
||||
0.4f to Color(0xFF8E99A8),
|
||||
0.5f to Color(0xFFEDF2F7),
|
||||
0.62f to Color(0xFF9AA5B4),
|
||||
1f to Color(0xFF6C7787),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
0f to Color(0xFF3B424C),
|
||||
0.16f to Color(0xFF2A303A),
|
||||
0.4f to Color(0xFF1B2028),
|
||||
0.5f to Color(0xFF343B45),
|
||||
0.62f to Color(0xFF232830),
|
||||
1f to Color(0xFF141820),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SurfaceStyle.Icy -> {
|
||||
val colors = if (isLight) {
|
||||
listOf(Color(0xFFF4FCFF), Color(0xFFCBE9FB), Color(0xFF9BD3F0))
|
||||
} else {
|
||||
listOf(Color(0xFF2A3B4D), Color(0xFF1B2A39), Color(0xFF0F1A26))
|
||||
}
|
||||
drawRect(brush = Brush.verticalGradient(colors))
|
||||
}
|
||||
|
||||
SurfaceStyle.Watery -> {
|
||||
// A clear-but-soft aqua column: unmistakably water, without a heavy flat blue.
|
||||
val colors = if (isLight) {
|
||||
listOf(Color(0xFFD8F0FD), Color(0xFFB0DEF7), Color(0xFF83C8EE))
|
||||
} else {
|
||||
listOf(Color(0xFF123249), Color(0xFF0B2135), Color(0xFF06141F))
|
||||
}
|
||||
drawRect(brush = Brush.verticalGradient(colors))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The accent-filled base used by [SurfaceRole.Action]. */
|
||||
private fun DrawScope.drawAccentBase(accent: Color) {
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(sheen.copy(alpha = if (isLight) 0.35f else 0.22f), Color.Transparent),
|
||||
startY = 0f,
|
||||
endY = size.height * 0.5f,
|
||||
listOf(
|
||||
lerp(accent, Color.White, 0.45f),
|
||||
accent,
|
||||
lerp(accent, Color.Black, 0.16f),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val random = Random(53)
|
||||
repeat(5) {
|
||||
val rx = size.width * (0.12f + random.nextFloat() * 0.2f)
|
||||
val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f)
|
||||
val cy = rx * 0.5f + random.nextFloat() * (size.height * 0.6f - rx).coerceAtLeast(0f)
|
||||
drawOval(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(sheen.copy(alpha = if (isLight) 0.3f else 0.18f), Color.Transparent),
|
||||
center = Offset(cx, cy),
|
||||
radius = rx,
|
||||
),
|
||||
topLeft = Offset(cx - rx, cy - rx * 0.5f),
|
||||
size = Size(rx * 2f, rx),
|
||||
)
|
||||
// --- Material decorations (one per style) ------------------------------------
|
||||
|
||||
private fun DrawScope.drawStyleDecorations(style: SurfaceStyle, isLight: Boolean) {
|
||||
when (style) {
|
||||
SurfaceStyle.Glass -> {
|
||||
drawGloss(if (isLight) 0.55f else 0.16f)
|
||||
drawBottomReflection(if (isLight) 0.4f else 0.1f)
|
||||
drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f)
|
||||
}
|
||||
|
||||
SurfaceStyle.Acrylic -> {
|
||||
// Fine grain so the panel reads as a translucent acrylic sheet.
|
||||
val random = Random(23)
|
||||
repeat(180) {
|
||||
val light = random.nextBoolean()
|
||||
drawCircle(
|
||||
color = if (light) {
|
||||
Color.White.copy(alpha = if (isLight) 0.16f else 0.06f)
|
||||
} else {
|
||||
Color.Black.copy(alpha = if (isLight) 0.06f else 0.08f)
|
||||
},
|
||||
radius = 0.7f,
|
||||
center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height),
|
||||
)
|
||||
}
|
||||
drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f)
|
||||
}
|
||||
|
||||
SurfaceStyle.Chrome -> {
|
||||
// Hard specular reflection near the top.
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = if (isLight) 0.85f else 0.18f),
|
||||
Color.Transparent,
|
||||
),
|
||||
startY = size.height * 0.05f,
|
||||
endY = size.height * 0.22f,
|
||||
),
|
||||
)
|
||||
// Darkened lower edge for the metal bevel.
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.25f)),
|
||||
startY = size.height * 0.78f,
|
||||
endY = size.height,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
SurfaceStyle.Icy -> {
|
||||
val frost = if (isLight) Color.White else Color(0xFFB8CCE0)
|
||||
|
||||
// Crystalline facets (kept inset so they are never clipped by the shape).
|
||||
val random = Random(37)
|
||||
repeat(4) {
|
||||
val s = size.minDimension * (0.2f + random.nextFloat() * 0.3f)
|
||||
val x = random.nextFloat() * (size.width - s).coerceAtLeast(0f)
|
||||
val y = random.nextFloat() * (size.height - s).coerceAtLeast(0f)
|
||||
val path = Path().apply {
|
||||
moveTo(x, y)
|
||||
lineTo(x + s, y + s * 0.4f)
|
||||
lineTo(x + s * 0.4f, y + s)
|
||||
close()
|
||||
}
|
||||
drawPath(path, frost.copy(alpha = if (isLight) 0.25f else 0.12f))
|
||||
}
|
||||
|
||||
// Frost speckles.
|
||||
repeat(90) {
|
||||
drawCircle(
|
||||
color = frost.copy(alpha = if (isLight) 0.5f else 0.28f),
|
||||
radius = 0.8f,
|
||||
center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height),
|
||||
)
|
||||
}
|
||||
drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f)
|
||||
}
|
||||
|
||||
SurfaceStyle.Watery -> {
|
||||
val sheen = if (isLight) Color.White else Color(0xFFAFD3F2)
|
||||
|
||||
// Caustic light pooling from the surface.
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
sheen.copy(alpha = if (isLight) 0.6f else 0.2f),
|
||||
Color.Transparent,
|
||||
),
|
||||
startY = 0f,
|
||||
endY = size.height * 0.55f,
|
||||
),
|
||||
)
|
||||
|
||||
val random = Random(53)
|
||||
// Refracted caustic blobs — the bright, rippling patterns of moving water.
|
||||
repeat(9) {
|
||||
val rx = size.minDimension * (0.14f + random.nextFloat() * 0.26f)
|
||||
val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f)
|
||||
val cy = rx + random.nextFloat() * (size.height - 2 * rx).coerceAtLeast(0f)
|
||||
drawOval(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
sheen.copy(alpha = if (isLight) 0.5f else 0.22f),
|
||||
Color.Transparent,
|
||||
),
|
||||
center = Offset(cx, cy),
|
||||
radius = rx,
|
||||
),
|
||||
topLeft = Offset(cx - rx, cy - rx * 0.7f),
|
||||
size = Size(rx * 2f, rx * 1.4f),
|
||||
)
|
||||
}
|
||||
|
||||
// Ripple crests: thin horizontal highlights that read as surface waves.
|
||||
repeat(3) {
|
||||
val y = size.height * (0.18f + random.nextFloat() * 0.64f)
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
sheen.copy(alpha = if (isLight) 0.4f else 0.16f),
|
||||
Color.Transparent,
|
||||
),
|
||||
startY = y - size.height * 0.035f,
|
||||
endY = y + size.height * 0.035f,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
drawBubbles(seed = 71, count = 11, alpha = if (isLight) 0.6f else 0.3f, rim = true)
|
||||
}
|
||||
}
|
||||
|
||||
drawBubbles(seed = 71, count = 6, alpha = if (isLight) 0.4f else 0.22f)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawGloss(alpha: Float, heightFraction: Float = 0.5f) {
|
||||
@ -406,7 +544,7 @@ private fun DrawScope.drawInnerBevel(isLight: Boolean) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float) {
|
||||
private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float, rim: Boolean = false) {
|
||||
val random = Random(seed)
|
||||
repeat(count) {
|
||||
val radius = size.minDimension * (0.05f + random.nextFloat() * 0.14f)
|
||||
@ -423,59 +561,97 @@ private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float) {
|
||||
radius = radius,
|
||||
center = center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@BaseUIDesktopPreview
|
||||
@Composable
|
||||
private fun BaseSurfacePreview() {
|
||||
BaseUIPreview {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
SurfaceStyle.entries.forEach { style ->
|
||||
BaseSurface(
|
||||
style = style,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(84.dp),
|
||||
) {
|
||||
Text(
|
||||
text = style.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (rim) {
|
||||
drawCircle(
|
||||
color = Color.White.copy(alpha = alpha * 0.7f),
|
||||
radius = radius,
|
||||
center = center,
|
||||
style = Stroke(width = (radius * 0.14f).coerceAtLeast(0.8f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
private fun styleBorderBrush(style: SurfaceStyle): Brush = when (style) {
|
||||
SurfaceStyle.Watery -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)),
|
||||
)
|
||||
|
||||
SurfaceStyle.Icy -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.95f), Color.White.copy(alpha = 0.35f)),
|
||||
)
|
||||
|
||||
SurfaceStyle.Chrome -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.95f), Color.Black.copy(alpha = 0.28f)),
|
||||
)
|
||||
|
||||
SurfaceStyle.Acrylic -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.12f)),
|
||||
)
|
||||
|
||||
SurfaceStyle.Glass -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.9f), Color.White.copy(alpha = 0.3f)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun roleBorderBrush(
|
||||
style: SurfaceStyle,
|
||||
role: SurfaceRole,
|
||||
isLight: Boolean,
|
||||
scheme: BaseUIColorScheme,
|
||||
): Brush = when (role) {
|
||||
SurfaceRole.PrimaryBackground -> styleBorderBrush(style)
|
||||
|
||||
SurfaceRole.SecondaryBackground -> Brush.verticalGradient(
|
||||
listOf(
|
||||
lerp(scheme.backgroundSecondary, Color.White, 0.5f)
|
||||
.copy(alpha = if (isLight) 0.85f else 0.6f),
|
||||
Color.White.copy(alpha = 0.12f),
|
||||
),
|
||||
)
|
||||
|
||||
SurfaceRole.PrimaryAccent -> accentEdgeBrush(scheme.accentPrimary)
|
||||
SurfaceRole.SecondaryAccent -> accentEdgeBrush(scheme.accentSecondary)
|
||||
|
||||
SurfaceRole.InverseBackground -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.1f)),
|
||||
)
|
||||
|
||||
SurfaceRole.InverseAction -> accentEdgeBrush(scheme.actionInverse)
|
||||
}
|
||||
|
||||
private fun accentEdgeBrush(accent: Color): Brush = Brush.verticalGradient(
|
||||
listOf(lerp(accent, Color.White, 0.6f), lerp(accent, Color.Black, 0.2f)),
|
||||
)
|
||||
|
||||
/**
|
||||
* One preview showing everything about surfaces: every [SurfaceStyle] crossed with every
|
||||
* [SurfaceRole], plus the full-bleed backdrops — no need to scroll through many previews.
|
||||
*/
|
||||
@BaseUIDesktopPreview
|
||||
@Composable
|
||||
private fun BaseSurfaceGridPreview() {
|
||||
private fun SurfacePreview() {
|
||||
BaseUIPreview {
|
||||
val colors = LocalBaseUIColors.current
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
SurfaceStyle.entries.chunked(2).forEach { rowStyles ->
|
||||
SurfaceStyle.entries.forEach { style ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
rowStyles.forEach { style ->
|
||||
SurfaceRole.entries.forEach { role ->
|
||||
BaseSurface(
|
||||
style = style,
|
||||
role = role,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(140.dp),
|
||||
.height(84.dp),
|
||||
contentPadding = PaddingValues(10.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@ -483,54 +659,41 @@ private fun BaseSurfaceGridPreview() {
|
||||
) {
|
||||
Text(
|
||||
text = style.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = "Frutiger Aero",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
text = role.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
repeat(2 - rowStyles.size) {
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
// Full-bleed backdrops (the page background material, without the panel bevel).
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
SurfaceStyle.entries.forEach { style ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(60.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.baseSurfaceBackdrop(style, colors.isLight),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = style.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colors.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@BaseUIDesktopPreview
|
||||
@Composable
|
||||
private fun SurfaceThemeBackdropPreview() {
|
||||
val isLight = LocalBaseUIColors.current.isLight
|
||||
val onSurface = LocalBaseUIColors.current.onSurface
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
SurfaceStyle.entries.forEach { style ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(66.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.baseSurfaceBackdrop(style, isLight),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
text = style.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (style == SurfaceStyle.Watery) Color.White else onSurface,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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()
|
||||
@ -23,45 +23,48 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.Droplet
|
||||
import compose.icons.feathericons.Monitor
|
||||
import spotube.composeapp.generated.resources.*
|
||||
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseSurface
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||
import dev.krtirtho.spotube.core.ui.base.Radio
|
||||
import dev.krtirtho.spotube.core.ui.base.SurfaceRole
|
||||
import dev.krtirtho.spotube.core.ui.base.SurfaceStyle
|
||||
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
||||
import dev.krtirtho.spotube.core.ui.base.baseUIColorScheme
|
||||
import dev.krtirtho.spotube.modules.settings.AccentColors
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||
import dev.krtirtho.spotube.modules.settings.Theme
|
||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||
import dev.krtirtho.spotube.modules.settings.colorFor
|
||||
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
|
||||
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
|
||||
import dev.krtirtho.spotube.modules.settings.recommendedAccents
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxColorsSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.*
|
||||
@ -97,39 +100,23 @@ internal fun LazyListScope.appearanceSection(
|
||||
)
|
||||
},
|
||||
{
|
||||
SelectionSettingCard(
|
||||
title = stringResource(Res.string.settings_surface_theme_title),
|
||||
subtitle = stringResource(
|
||||
Res.string.settings_surface_theme_subtitle_current,
|
||||
settings.surfaceTheme.displayLabel()
|
||||
),
|
||||
// Surface material and accent are one decision: each surface only offers the
|
||||
// accents that are known to pair with it, so the result is always a curated,
|
||||
// good-looking combination instead of an arbitrary one.
|
||||
AppearanceSettingCard(
|
||||
selectedSurface = settings.surfaceTheme,
|
||||
selectedAccent = settings.accentColor,
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxMagic,
|
||||
stringResource(Res.string.settings_surface_theme_title)
|
||||
stringResource(Res.string.settings_appearance_title)
|
||||
)
|
||||
},
|
||||
selectedOption = settings.surfaceTheme,
|
||||
options = SurfaceStyle.entries,
|
||||
optionLabel = { it.displayLabel() },
|
||||
onOptionSelected = { surfaceTheme ->
|
||||
onSaved = { surface, accent ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(surfaceTheme = surfaceTheme)
|
||||
copy(surfaceTheme = surface, accentColor = accent)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
AccentColorSettingCard(
|
||||
selectedAccent = settings.accentColor,
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxColorsSquare, stringResource(Res.string.settings_accent_title))
|
||||
},
|
||||
onColorSaved = { accent ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(accentColor = accent)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
@ -170,43 +157,53 @@ private fun AccentColors.displayLabel(): String {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccentColorSettingCard(
|
||||
private fun AppearanceSettingCard(
|
||||
selectedSurface: SurfaceStyle,
|
||||
selectedAccent: AccentColors,
|
||||
icon: (@Composable () -> Unit)? = null,
|
||||
onColorSaved: (AccentColors) -> Unit,
|
||||
onSaved: (SurfaceStyle, AccentColors) -> Unit,
|
||||
) {
|
||||
var isDialogOpen by remember { mutableStateOf(false) }
|
||||
val isLight = LocalBaseUIColors.current.isLight
|
||||
|
||||
SettingCardItem(
|
||||
title = stringResource(Res.string.settings_accent_title),
|
||||
title = stringResource(Res.string.settings_appearance_title),
|
||||
subtitle = stringResource(
|
||||
Res.string.settings_accent_subtitle_current,
|
||||
selectedAccent.displayLabel()
|
||||
Res.string.settings_appearance_subtitle_current,
|
||||
selectedSurface.displayLabel(),
|
||||
selectedAccent.displayLabel(),
|
||||
),
|
||||
icon = icon,
|
||||
trailingContent = {
|
||||
AccentDualPreview(
|
||||
lightAccent = selectedAccent.toLightColor(),
|
||||
darkAccent = selectedAccent.toDarkColor(),
|
||||
AppearanceTrailingSwatch(
|
||||
surface = selectedSurface,
|
||||
accent = selectedAccent,
|
||||
isLight = isLight,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
isDialogOpen = true
|
||||
}
|
||||
onClick = { isDialogOpen = true },
|
||||
)
|
||||
|
||||
if (isDialogOpen) {
|
||||
var draftAccent by remember(selectedAccent, isDialogOpen) { mutableStateOf(selectedAccent) }
|
||||
var draftSurface by remember(selectedSurface, isDialogOpen) {
|
||||
mutableStateOf(selectedSurface)
|
||||
}
|
||||
var draftAccent by remember(selectedSurface, selectedAccent, isDialogOpen) {
|
||||
mutableStateOf(selectedAccent)
|
||||
}
|
||||
|
||||
ThemedDialog(
|
||||
onDismissRequest = { isDialogOpen = false },
|
||||
title = {
|
||||
Text(stringResource(Res.string.settings_accent_dialog_title), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
stringResource(Res.string.settings_appearance_dialog_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
PrimaryButton(
|
||||
onClick = {
|
||||
onColorSaved(draftAccent)
|
||||
onSaved(draftSurface, draftAccent)
|
||||
isDialogOpen = false
|
||||
}
|
||||
) {
|
||||
@ -217,141 +214,245 @@ private fun AccentColorSettingCard(
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_accent_dialog_description),
|
||||
text = stringResource(Res.string.settings_appearance_dialog_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_appearance_surface_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
AccentThemePreview(
|
||||
title = stringResource(Res.string.settings_preview_light),
|
||||
accent = draftAccent.toLightColor(),
|
||||
background = Color(0xFFFFFFFF),
|
||||
textColor = Color(0xFF121212),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
AccentThemePreview(
|
||||
title = stringResource(Res.string.settings_preview_dark),
|
||||
accent = draftAccent.toDarkColor(),
|
||||
background = Color(0xFF121212),
|
||||
textColor = Color(0xFFEDEDED),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SurfaceStyle.entries.forEach { style ->
|
||||
SurfaceStyleTile(
|
||||
style = style,
|
||||
accent = draftAccent,
|
||||
isLight = isLight,
|
||||
selected = style == draftSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
draftSurface = style
|
||||
if (draftAccent !in style.recommendedAccents) {
|
||||
draftAccent = style.recommendedAccents.first()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
AccentColors.entries.forEach { option ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { draftAccent = option }
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Radio(
|
||||
selected = option == draftAccent,
|
||||
onClick = { draftAccent = option }
|
||||
)
|
||||
Text(
|
||||
text = option.displayLabel(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
AccentDualPreview(
|
||||
lightAccent = option.toLightColor(),
|
||||
darkAccent = option.toDarkColor(),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_appearance_accent_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
draftSurface.recommendedAccents.forEach { accent ->
|
||||
AccentSwatch(
|
||||
accent = accent,
|
||||
label = accent.displayLabel(),
|
||||
isLight = isLight,
|
||||
selected = accent == draftAccent,
|
||||
onClick = { draftAccent = accent },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AppearancePreview(
|
||||
surface = draftSurface,
|
||||
accent = draftAccent,
|
||||
isLight = isLight,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SurfaceStyleTile(
|
||||
style: SurfaceStyle,
|
||||
accent: AccentColors,
|
||||
isLight: Boolean,
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val scheme = remember(style, accent, isLight) {
|
||||
baseUIColorScheme(style, isLight, accent.colorFor(isLight))
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(70.dp)
|
||||
.then(
|
||||
if (selected) {
|
||||
Modifier
|
||||
.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(14.dp))
|
||||
.padding(2.dp)
|
||||
} else {
|
||||
Modifier.padding(2.dp)
|
||||
}
|
||||
),
|
||||
) {
|
||||
CompositionLocalProvider(LocalBaseUIColors provides scheme) {
|
||||
BaseSurface(
|
||||
style = style,
|
||||
role = SurfaceRole.PrimaryBackground,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = style.displayLabel(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.background(accent.colorFor(isLight), CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccentDualPreview(
|
||||
lightAccent: Color,
|
||||
darkAccent: Color,
|
||||
private fun AccentSwatch(
|
||||
accent: AccentColors,
|
||||
label: String,
|
||||
isLight: Boolean,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.background(Color.White, RoundedCornerShape(6.dp))
|
||||
.border(1.dp, Color(0x22000000), RoundedCornerShape(6.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.background(lightAccent, CircleShape)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
.clip(CircleShape)
|
||||
.background(accent.colorFor(isLight))
|
||||
.border(
|
||||
width = if (selected) 3.dp else 1.dp,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
},
|
||||
shape = CircleShape,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.background(Color(0xFF121212), RoundedCornerShape(6.dp))
|
||||
.border(1.dp, Color(0x33FFFFFF), RoundedCornerShape(6.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
.clickable(onClick = onClick)
|
||||
.semantics { contentDescription = label },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppearanceTrailingSwatch(
|
||||
surface: SurfaceStyle,
|
||||
accent: AccentColors,
|
||||
isLight: Boolean,
|
||||
) {
|
||||
val scheme = remember(surface, accent, isLight) {
|
||||
baseUIColorScheme(surface, isLight, accent.colorFor(isLight))
|
||||
}
|
||||
CompositionLocalProvider(LocalBaseUIColors provides scheme) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.background(darkAccent, CircleShape)
|
||||
)
|
||||
BaseSurface(
|
||||
style = surface,
|
||||
role = SurfaceRole.PrimaryBackground,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.size(width = 46.dp, height = 28.dp),
|
||||
) {}
|
||||
BaseSurface(
|
||||
style = surface,
|
||||
role = SurfaceRole.PrimaryAccent,
|
||||
shape = CircleShape,
|
||||
modifier = Modifier.size(18.dp),
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccentThemePreview(
|
||||
title: String,
|
||||
accent: Color,
|
||||
background: Color,
|
||||
textColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
private fun AppearancePreview(
|
||||
surface: SurfaceStyle,
|
||||
accent: AccentColors,
|
||||
isLight: Boolean,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(background, RoundedCornerShape(10.dp))
|
||||
.border(1.dp, textColor.copy(alpha = 0.16f), RoundedCornerShape(10.dp))
|
||||
.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.labelMedium, color = textColor)
|
||||
Box(
|
||||
val scheme = remember(surface, accent, isLight) {
|
||||
baseUIColorScheme(surface, isLight, accent.colorFor(isLight))
|
||||
}
|
||||
CompositionLocalProvider(LocalBaseUIColors provides scheme) {
|
||||
BaseSurface(
|
||||
style = surface,
|
||||
role = SurfaceRole.PrimaryBackground,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.background(accent, RoundedCornerShape(999.dp))
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
.height(150.dp),
|
||||
contentPadding = PaddingValues(14.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.background(accent, CircleShape)
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.settings_preview_label),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
stringResource(Res.string.settings_preview_button),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = accent
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_preview_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
BaseSurface(
|
||||
style = surface,
|
||||
role = SurfaceRole.PrimaryAccent,
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.settings_preview_button),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
BaseSurface(
|
||||
style = surface,
|
||||
role = SurfaceRole.SecondaryAccent,
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.settings_preview_secondary),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
BaseSurface(
|
||||
style = surface,
|
||||
role = SurfaceRole.SecondaryBackground,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentPadding = PaddingValues(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_preview_label),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user