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_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 & 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_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>
|
||||||
|
|||||||
@ -22,11 +22,13 @@ import androidx.compose.animation.SharedTransitionLayout
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
@ -157,7 +159,16 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
SpotubeTheme(settings = userSettings) {
|
SpotubeTheme(settings = userSettings) {
|
||||||
val baseUITheme = rememberBaseUITheme(surfaceTheme = userSettings.surfaceTheme)
|
val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||||
|
val accent = if (isDark) {
|
||||||
|
userSettings.accentColor.toDarkColor()
|
||||||
|
} else {
|
||||||
|
userSettings.accentColor.toLightColor()
|
||||||
|
}
|
||||||
|
val baseUITheme = rememberBaseUITheme(
|
||||||
|
surfaceTheme = userSettings.surfaceTheme,
|
||||||
|
accentColor = accent,
|
||||||
|
)
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalBaseUITheme provides baseUITheme,
|
LocalBaseUITheme provides baseUITheme,
|
||||||
LocalBaseUIColors provides baseUITheme.colors,
|
LocalBaseUIColors provides baseUITheme.colors,
|
||||||
|
|||||||
@ -55,6 +55,18 @@ data class BaseUIColorScheme(
|
|||||||
val onPrimaryContainer: Color,
|
val onPrimaryContainer: Color,
|
||||||
val secondaryContainer: Color,
|
val secondaryContainer: Color,
|
||||||
val onSecondaryContainer: Color,
|
val onSecondaryContainer: Color,
|
||||||
|
val backgroundPrimary: Color,
|
||||||
|
val onBackgroundPrimary: Color,
|
||||||
|
val backgroundSecondary: Color,
|
||||||
|
val onBackgroundSecondary: Color,
|
||||||
|
val accentPrimary: Color,
|
||||||
|
val onAccentPrimary: Color,
|
||||||
|
val accentSecondary: Color,
|
||||||
|
val onAccentSecondary: Color,
|
||||||
|
val backgroundInverse: Color,
|
||||||
|
val onBackgroundInverse: Color,
|
||||||
|
val actionInverse: Color,
|
||||||
|
val onActionInverse: Color,
|
||||||
val outline: Color,
|
val outline: Color,
|
||||||
val outlineVariant: Color,
|
val outlineVariant: Color,
|
||||||
val error: Color,
|
val error: Color,
|
||||||
@ -79,12 +91,52 @@ fun rememberBaseUIColorScheme(
|
|||||||
accentColor: Color? = null,
|
accentColor: Color? = null,
|
||||||
): BaseUIColorScheme {
|
): BaseUIColorScheme {
|
||||||
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
|
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
|
||||||
val accent = accentColor ?: MaterialTheme.colorScheme.primary
|
val accent = accentColor ?: surfaceTheme.accentColor(isLight)
|
||||||
return remember(surfaceTheme, isLight, accent) {
|
return remember(surfaceTheme, isLight, accent) {
|
||||||
baseUIColorScheme(surfaceTheme, isLight, accent)
|
baseUIColorScheme(surfaceTheme, isLight, accent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default accent colour that characterises a surface theme. Every theme has its own,
|
||||||
|
* so a Watery app is aqua and a Chrome app is steel, unless an accent is passed explicitly.
|
||||||
|
*/
|
||||||
|
fun SurfaceStyle.accentColor(isLight: Boolean): Color = when (this) {
|
||||||
|
SurfaceStyle.Watery -> if (isLight) Color(0xFF0E8FD6) else Color(0xFF4FC3F7)
|
||||||
|
SurfaceStyle.Icy -> if (isLight) Color(0xFF3FA9E0) else Color(0xFF7FD8FF)
|
||||||
|
SurfaceStyle.Chrome -> if (isLight) Color(0xFF5B6B7C) else Color(0xFF9AA5B4)
|
||||||
|
SurfaceStyle.Acrylic -> if (isLight) Color(0xFF5C6BC0) else Color(0xFF8C9EFF)
|
||||||
|
SurfaceStyle.Glass -> if (isLight) Color(0xFF7C4DFF) else Color(0xFFB388FF)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The secondary accent is derived from the primary accent as a complementary hue, so it always
|
||||||
|
* shifts with — and harmonises with — the chosen accent instead of being fixed per style.
|
||||||
|
*/
|
||||||
|
private fun Color.complementaryAccent(hueShift: Float = 180f): Color {
|
||||||
|
val (hue, saturation, value) = toHsv()
|
||||||
|
return Color.hsv(
|
||||||
|
(hue + hueShift + 360f) % 360f,
|
||||||
|
saturation.coerceIn(0.45f, 0.9f),
|
||||||
|
value.coerceIn(0.45f, 0.95f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Color.toHsv(): Triple<Float, Float, Float> {
|
||||||
|
val max = maxOf(red, green, blue)
|
||||||
|
val min = minOf(red, green, blue)
|
||||||
|
val delta = max - min
|
||||||
|
val rawHue = when {
|
||||||
|
delta == 0f -> 0f
|
||||||
|
max == red -> 60f * (((green - blue) / delta) % 6f)
|
||||||
|
max == green -> 60f * (((blue - red) / delta) + 2f)
|
||||||
|
else -> 60f * (((red - green) / delta) + 4f)
|
||||||
|
}
|
||||||
|
val hue = if (rawHue < 0f) rawHue + 360f else rawHue
|
||||||
|
val saturation = if (max == 0f) 0f else delta / max
|
||||||
|
return Triple(hue, saturation, max)
|
||||||
|
}
|
||||||
|
|
||||||
fun baseUIColorScheme(
|
fun baseUIColorScheme(
|
||||||
surfaceTheme: SurfaceStyle,
|
surfaceTheme: SurfaceStyle,
|
||||||
isLight: Boolean,
|
isLight: Boolean,
|
||||||
@ -99,6 +151,21 @@ fun baseUIColorScheme(
|
|||||||
lerp(accent, Color.White, 0.7f)
|
lerp(accent, Color.White, 0.7f)
|
||||||
}
|
}
|
||||||
val secondaryContainer = lerp(palette.surface, accent, if (isLight) 0.22f else 0.34f)
|
val secondaryContainer = lerp(palette.surface, accent, if (isLight) 0.22f else 0.34f)
|
||||||
|
val inversePalette = surfacePalette(surfaceTheme, !isLight)
|
||||||
|
val secondaryAccent = accent.complementaryAccent()
|
||||||
|
val onSecondaryAccent = if (secondaryAccent.luminance() > 0.5f) Color(0xFF10161C) else Color.White
|
||||||
|
val secondaryBackground = if (isLight) {
|
||||||
|
lerp(palette.surface, secondaryAccent, 0.14f)
|
||||||
|
} else {
|
||||||
|
lerp(palette.surface, secondaryAccent, 0.22f)
|
||||||
|
}
|
||||||
|
val onSecondaryBackground = if (secondaryBackground.luminance() > 0.5f) {
|
||||||
|
lerp(secondaryAccent, Color.Black, 0.6f)
|
||||||
|
} else {
|
||||||
|
lerp(secondaryAccent, Color.White, 0.82f)
|
||||||
|
}
|
||||||
|
val actionInverse = if (isLight) lerp(accent, Color.Black, 0.45f) else lerp(accent, Color.White, 0.5f)
|
||||||
|
val onActionInverse = if (actionInverse.luminance() > 0.5f) Color(0xFF10161C) else Color.White
|
||||||
|
|
||||||
return BaseUIColorScheme(
|
return BaseUIColorScheme(
|
||||||
surfaceTheme = surfaceTheme,
|
surfaceTheme = surfaceTheme,
|
||||||
@ -120,6 +187,18 @@ fun baseUIColorScheme(
|
|||||||
onPrimaryContainer = onPrimaryContainer,
|
onPrimaryContainer = onPrimaryContainer,
|
||||||
secondaryContainer = secondaryContainer,
|
secondaryContainer = secondaryContainer,
|
||||||
onSecondaryContainer = palette.onSurface,
|
onSecondaryContainer = palette.onSurface,
|
||||||
|
backgroundPrimary = palette.surface,
|
||||||
|
onBackgroundPrimary = palette.onSurface,
|
||||||
|
backgroundSecondary = secondaryBackground,
|
||||||
|
onBackgroundSecondary = onSecondaryBackground,
|
||||||
|
accentPrimary = accent,
|
||||||
|
onAccentPrimary = onPrimary,
|
||||||
|
accentSecondary = secondaryAccent,
|
||||||
|
onAccentSecondary = onSecondaryAccent,
|
||||||
|
backgroundInverse = inversePalette.surface,
|
||||||
|
onBackgroundInverse = inversePalette.onSurface,
|
||||||
|
actionInverse = actionInverse,
|
||||||
|
onActionInverse = onActionInverse,
|
||||||
outline = palette.outline,
|
outline = palette.outline,
|
||||||
outlineVariant = palette.outlineVariant,
|
outlineVariant = palette.outlineVariant,
|
||||||
error = if (isLight) Color(0xFFC0392B) else Color(0xFFE57373),
|
error = if (isLight) Color(0xFFC0392B) else Color(0xFFE57373),
|
||||||
@ -128,7 +207,7 @@ fun baseUIColorScheme(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class SurfacePalette(
|
internal class SurfacePalette(
|
||||||
val backgroundTop: Color,
|
val backgroundTop: Color,
|
||||||
val backgroundBottom: Color,
|
val backgroundBottom: Color,
|
||||||
val surface: Color,
|
val surface: Color,
|
||||||
@ -143,7 +222,7 @@ private class SurfacePalette(
|
|||||||
val outlineVariant: Color,
|
val outlineVariant: Color,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette {
|
internal fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette {
|
||||||
return if (isLight) {
|
return if (isLight) {
|
||||||
when (style) {
|
when (style) {
|
||||||
SurfaceStyle.Glass -> SurfacePalette(
|
SurfaceStyle.Glass -> SurfacePalette(
|
||||||
@ -177,18 +256,18 @@ private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalett
|
|||||||
)
|
)
|
||||||
|
|
||||||
SurfaceStyle.Watery -> SurfacePalette(
|
SurfaceStyle.Watery -> SurfacePalette(
|
||||||
backgroundTop = Color(0xFFD6F0FF),
|
backgroundTop = Color(0xFFEAF7FE),
|
||||||
backgroundBottom = Color(0xFFB4DFFA),
|
backgroundBottom = Color(0xFFD8EFFB),
|
||||||
surface = Color(0xFFF0FAFF),
|
surface = Color(0xFFF0FAFF),
|
||||||
surfaceVariant = Color(0xFFDCF0FC),
|
surfaceVariant = Color(0xFFE2F2FB),
|
||||||
surfaceContainer = Color(0xFFE7F5FE),
|
surfaceContainer = Color(0xFFEAF6FD),
|
||||||
surfaceContainerHigh = Color(0xFFEFF9FF),
|
surfaceContainerHigh = Color(0xFFF2FAFE),
|
||||||
surfaceContainerHighest = Color(0xFFCFE9F8),
|
surfaceContainerHighest = Color(0xFFD8ECF8),
|
||||||
onBackground = Color(0xFF08324A),
|
onBackground = Color(0xFF08324A),
|
||||||
onSurface = Color(0xFF08324A),
|
onSurface = Color(0xFF08324A),
|
||||||
onSurfaceVariant = Color(0xFF3A6884),
|
onSurfaceVariant = Color(0xFF3A6884),
|
||||||
outline = Color(0xFF8FBBD4),
|
outline = Color(0xFF9EC4DA),
|
||||||
outlineVariant = Color(0xFFC6E2F2),
|
outlineVariant = Color(0xFFD0E7F4),
|
||||||
)
|
)
|
||||||
|
|
||||||
SurfaceStyle.Icy -> SurfacePalette(
|
SurfaceStyle.Icy -> SurfacePalette(
|
||||||
@ -254,8 +333,8 @@ private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalett
|
|||||||
)
|
)
|
||||||
|
|
||||||
SurfaceStyle.Watery -> SurfacePalette(
|
SurfaceStyle.Watery -> SurfacePalette(
|
||||||
backgroundTop = Color(0xFF06121E),
|
backgroundTop = Color(0xFF0A1622),
|
||||||
backgroundBottom = Color(0xFF0B1F31),
|
backgroundBottom = Color(0xFF0E1E2D),
|
||||||
surface = Color(0xFF0E2233),
|
surface = Color(0xFF0E2233),
|
||||||
surfaceVariant = Color(0xFF153148),
|
surfaceVariant = Color(0xFF153148),
|
||||||
surfaceContainer = Color(0xFF0B1D2C),
|
surfaceContainer = Color(0xFF0B1D2C),
|
||||||
|
|||||||
@ -138,6 +138,27 @@ private fun Modifier.applyShadow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlays the ambient [SurfaceStyle] material for a colour's [BaseUITheme.ButtonColors.surfaceRole],
|
||||||
|
* so an actionable item looks exactly like the matching [BaseSurface] role.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.applySurfaceMaterial(colors: BaseUITheme.ButtonColors): Modifier =
|
||||||
|
applySurfaceMaterial(colors.surfaceRole)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.applySurfaceMaterial(role: SurfaceRole?): Modifier {
|
||||||
|
val scheme = LocalBaseUIColors.current
|
||||||
|
return role?.let { baseSurfaceTexture(scheme.surfaceTheme, it, scheme.isLight) } ?: this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The generic button gloss. Textured items get their sheen from the surface material, so it
|
||||||
|
* is only applied to colours without a [BaseUITheme.ButtonColors.surfaceRole].
|
||||||
|
*/
|
||||||
|
internal fun Modifier.applyHighlight(colors: BaseUITheme.ButtonColors): Modifier =
|
||||||
|
if (colors.surfaceRole == null) highlight(colors.highlight) else this
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OutlineButton(
|
fun OutlineButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
@ -172,6 +193,7 @@ fun OutlineButton(
|
|||||||
state.colors.background,
|
state.colors.background,
|
||||||
state.shape
|
state.shape
|
||||||
)
|
)
|
||||||
|
.applySurfaceMaterial(state.colors)
|
||||||
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
||||||
)
|
)
|
||||||
.clickable(
|
.clickable(
|
||||||
@ -180,7 +202,7 @@ fun OutlineButton(
|
|||||||
indication = ripple(),
|
indication = ripple(),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
)
|
)
|
||||||
.then(if (hoverOnly && !isHovered) Modifier else Modifier.highlight(state.colors.highlight))
|
.then(if (hoverOnly && !isHovered) Modifier else Modifier.applyHighlight(state.colors))
|
||||||
.padding(state.padding),
|
.padding(state.padding),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@ -221,6 +243,7 @@ fun PrimaryButton(
|
|||||||
.applyShadow(state.shadow, state.shape)
|
.applyShadow(state.shadow, state.shape)
|
||||||
.clip(state.shape)
|
.clip(state.shape)
|
||||||
.background(state.colors.background, state.shape)
|
.background(state.colors.background, state.shape)
|
||||||
|
.applySurfaceMaterial(state.colors)
|
||||||
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
||||||
.clickable(
|
.clickable(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
@ -228,7 +251,7 @@ fun PrimaryButton(
|
|||||||
indication = ripple(),
|
indication = ripple(),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
)
|
)
|
||||||
.highlight(state.colors.highlight)
|
.applyHighlight(state.colors)
|
||||||
.padding(state.padding),
|
.padding(state.padding),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@ -267,6 +290,7 @@ fun SecondaryButton(
|
|||||||
.applyShadow(state.shadow, state.shape)
|
.applyShadow(state.shadow, state.shape)
|
||||||
.clip(state.shape)
|
.clip(state.shape)
|
||||||
.background(state.colors.background, state.shape)
|
.background(state.colors.background, state.shape)
|
||||||
|
.applySurfaceMaterial(state.colors)
|
||||||
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
||||||
.clickable(
|
.clickable(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
@ -274,7 +298,7 @@ fun SecondaryButton(
|
|||||||
indication = ripple(),
|
indication = ripple(),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
)
|
)
|
||||||
.highlight(state.colors.highlight)
|
.applyHighlight(state.colors)
|
||||||
.padding(state.padding),
|
.padding(state.padding),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -75,6 +75,7 @@ fun Card(
|
|||||||
)
|
)
|
||||||
.clip(cardTheme.shape)
|
.clip(cardTheme.shape)
|
||||||
.background(cardTheme.background, cardTheme.shape)
|
.background(cardTheme.background, cardTheme.shape)
|
||||||
|
.applySurfaceMaterial(cardTheme.surfaceRole)
|
||||||
.border(BorderStroke(cardTheme.border.width, cardTheme.border.color), cardTheme.shape)
|
.border(BorderStroke(cardTheme.border.width, cardTheme.border.color), cardTheme.shape)
|
||||||
.padding(cardTheme.padding),
|
.padding(cardTheme.padding),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -146,8 +146,9 @@ fun CheckBox(
|
|||||||
)
|
)
|
||||||
.clip(resolved.shape)
|
.clip(resolved.shape)
|
||||||
.background(resolved.colors.background, resolved.shape)
|
.background(resolved.colors.background, resolved.shape)
|
||||||
|
.applySurfaceMaterial(resolved.colors)
|
||||||
.border(BorderStroke(0.5.dp, borderColor), resolved.shape)
|
.border(BorderStroke(0.5.dp, borderColor), resolved.shape)
|
||||||
.highlight(resolved.colors.highlight)
|
.applyHighlight(resolved.colors)
|
||||||
.then(
|
.then(
|
||||||
if (onClick != null) {
|
if (onClick != null) {
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
@ -123,6 +123,7 @@ fun ChipTab(
|
|||||||
.applyChipShadow(state.shadow, state.shape)
|
.applyChipShadow(state.shadow, state.shape)
|
||||||
.clip(state.shape)
|
.clip(state.shape)
|
||||||
.background(state.colors.background, state.shape)
|
.background(state.colors.background, state.shape)
|
||||||
|
.applySurfaceMaterial(state.colors)
|
||||||
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
||||||
.clickable(
|
.clickable(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
@ -130,7 +131,7 @@ fun ChipTab(
|
|||||||
indication = ripple(),
|
indication = ripple(),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
)
|
)
|
||||||
.highlight(state.colors.highlight)
|
.applyHighlight(state.colors)
|
||||||
.padding(state.padding),
|
.padding(state.padding),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -39,12 +39,14 @@ import androidx.compose.ui.unit.dp
|
|||||||
/**
|
/**
|
||||||
* A theme-aware replacement for [Scaffold].
|
* A theme-aware replacement for [Scaffold].
|
||||||
*
|
*
|
||||||
* Material's [Scaffold] paints Material's own surface colour, which hides the selected
|
* A scaffold is always the current theme's **Primary Background** — there is no option to
|
||||||
* [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass). [BaseScaffold] instead:
|
* switch its surface. Material's [Scaffold] would paint Material's own surface colour and
|
||||||
|
* hide the selected [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass), so [BaseScaffold]
|
||||||
|
* paints the theme's primary background material instead.
|
||||||
*
|
*
|
||||||
* - is **transparent by default**, so the app-wide themed backdrop shows through, or
|
* [containerColor] is a narrow escape hatch for the rare screen that paints its own
|
||||||
* - paints a chosen [surface] material locally, or
|
* backdrop (e.g. onboarding) and needs the scaffold to stay out of the way; it is not a
|
||||||
* - uses an explicit [containerColor] when one is given.
|
* surface-style switch.
|
||||||
*
|
*
|
||||||
* It keeps the same slot structure as [Scaffold] (top/bottom bars, snackbar host,
|
* It keeps the same slot structure as [Scaffold] (top/bottom bars, snackbar host,
|
||||||
* floating action button) and hands the resolved [PaddingValues] to [content].
|
* floating action button) and hands the resolved [PaddingValues] to [content].
|
||||||
@ -56,7 +58,6 @@ fun BaseScaffold(
|
|||||||
bottomBar: @Composable () -> Unit = {},
|
bottomBar: @Composable () -> Unit = {},
|
||||||
snackbarHost: @Composable () -> Unit = {},
|
snackbarHost: @Composable () -> Unit = {},
|
||||||
floatingActionButton: @Composable () -> Unit = {},
|
floatingActionButton: @Composable () -> Unit = {},
|
||||||
surface: SurfaceStyle? = null,
|
|
||||||
containerColor: Color? = null,
|
containerColor: Color? = null,
|
||||||
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
|
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||||
content: @Composable (PaddingValues) -> Unit,
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
@ -70,15 +71,9 @@ fun BaseScaffold(
|
|||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
contentWindowInsets = contentWindowInsets,
|
contentWindowInsets = contentWindowInsets,
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
val backgroundModifier = when {
|
val colors = LocalBaseUIColors.current
|
||||||
containerColor != null -> Modifier.background(containerColor)
|
val backgroundModifier = containerColor?.let { Modifier.background(it) }
|
||||||
surface != null -> {
|
?: Modifier.baseSurfaceBackdrop(colors.surfaceTheme, colors.isLight)
|
||||||
val isLight = LocalBaseUIColors.current.isLight
|
|
||||||
Modifier.baseSurfaceBackdrop(surface, isLight)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> Modifier
|
|
||||||
}
|
|
||||||
Box(modifier = Modifier.fillMaxSize().then(backgroundModifier)) {
|
Box(modifier = Modifier.fillMaxSize().then(backgroundModifier)) {
|
||||||
content(innerPadding)
|
content(innerPadding)
|
||||||
}
|
}
|
||||||
@ -90,9 +85,7 @@ fun BaseScaffold(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun BaseScaffoldPreview() {
|
private fun BaseScaffoldPreview() {
|
||||||
BaseUIPreview {
|
BaseUIPreview {
|
||||||
val style = LocalBaseUITheme.current.surfaceTheme
|
|
||||||
BaseScaffold(
|
BaseScaffold(
|
||||||
surface = style,
|
|
||||||
topBar = {
|
topBar = {
|
||||||
Text(
|
Text(
|
||||||
text = "Title",
|
text = "Title",
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -48,7 +49,8 @@ import androidx.compose.ui.graphics.Path
|
|||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@ -76,7 +78,31 @@ enum class SurfaceStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials.
|
* The role a surface plays within a theme. Each style produces a distinct material for
|
||||||
|
* every role, so a theme is composed from them:
|
||||||
|
*
|
||||||
|
* - [PrimaryBackground] — the subtle main background (pages, panels).
|
||||||
|
* - [SecondaryBackground] — a distinct, complementary background hue that pairs with the
|
||||||
|
* primary (grouped/alternate sections).
|
||||||
|
* - [PrimaryAccent] — the primary accent surface for interactive elements (buttons,
|
||||||
|
* toggles, active chips).
|
||||||
|
* - [SecondaryAccent] — a complementary accent surface (secondary actions, highlights).
|
||||||
|
* - [InverseBackground] — the inverted-brightness background (tooltips, snackbars).
|
||||||
|
* - [InverseAction] — the inverted-brightness action surface.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class SurfaceRole {
|
||||||
|
PrimaryBackground,
|
||||||
|
SecondaryBackground,
|
||||||
|
PrimaryAccent,
|
||||||
|
SecondaryAccent,
|
||||||
|
InverseBackground,
|
||||||
|
InverseAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials,
|
||||||
|
* in the given [SurfaceRole].
|
||||||
*
|
*
|
||||||
* Distinct from [androidx.compose.material3.Surface] — this draws decorative material
|
* Distinct from [androidx.compose.material3.Surface] — this draws decorative material
|
||||||
* backgrounds (gradients, gloss, grain, bubbles, facets) and provides a matching
|
* backgrounds (gradients, gloss, grain, bubbles, facets) and provides a matching
|
||||||
@ -85,7 +111,8 @@ enum class SurfaceStyle {
|
|||||||
@Composable
|
@Composable
|
||||||
fun BaseSurface(
|
fun BaseSurface(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
style: SurfaceStyle = SurfaceStyle.Glass,
|
style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme,
|
||||||
|
role: SurfaceRole = SurfaceRole.PrimaryBackground,
|
||||||
shape: Shape = RectangleShape,
|
shape: Shape = RectangleShape,
|
||||||
elevation: Dp? = null,
|
elevation: Dp? = null,
|
||||||
contentColor: Color? = null,
|
contentColor: Color? = null,
|
||||||
@ -93,63 +120,60 @@ fun BaseSurface(
|
|||||||
contentAlignment: Alignment = Alignment.TopStart,
|
contentAlignment: Alignment = Alignment.TopStart,
|
||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
|
val ambientScheme = LocalBaseUIColors.current
|
||||||
val onSurface = MaterialTheme.colorScheme.onSurface
|
val isLight = ambientScheme.isLight
|
||||||
|
// A surface is rendered in its own style's colour scheme, so previewing a style shows
|
||||||
|
// that style's real accent instead of the ambient theme's. When the requested style is
|
||||||
|
// the ambient theme, the ambient scheme is used so a user-chosen accent still applies.
|
||||||
|
val ownScheme = remember(style, isLight) {
|
||||||
|
baseUIColorScheme(style, isLight, style.accentColor(isLight))
|
||||||
|
}
|
||||||
|
val scheme = if (ambientScheme.surfaceTheme == style) ambientScheme else ownScheme
|
||||||
|
val palette = surfacePalette(style, isLight)
|
||||||
|
|
||||||
val resolvedElevation = elevation ?: when (style) {
|
val baseElevation = when (style) {
|
||||||
SurfaceStyle.Watery -> 14.dp
|
SurfaceStyle.Watery -> 14.dp
|
||||||
SurfaceStyle.Icy -> 12.dp
|
SurfaceStyle.Icy -> 12.dp
|
||||||
SurfaceStyle.Chrome -> 10.dp
|
SurfaceStyle.Chrome -> 10.dp
|
||||||
SurfaceStyle.Acrylic -> 16.dp
|
SurfaceStyle.Acrylic -> 16.dp
|
||||||
SurfaceStyle.Glass -> 12.dp
|
SurfaceStyle.Glass -> 12.dp
|
||||||
}
|
}
|
||||||
val resolvedContentColor = contentColor ?: when (style) {
|
val resolvedElevation = elevation ?: when (role) {
|
||||||
SurfaceStyle.Watery -> Color.White
|
SurfaceRole.PrimaryBackground -> baseElevation
|
||||||
SurfaceStyle.Icy -> if (isLight) Color(0xFF0B3A55) else Color(0xFFDCEBF7)
|
SurfaceRole.SecondaryBackground -> baseElevation + 2.dp
|
||||||
SurfaceStyle.Chrome -> if (isLight) Color(0xFF1B2733) else Color(0xFFE7ECF2)
|
SurfaceRole.PrimaryAccent, SurfaceRole.SecondaryAccent -> baseElevation + 6.dp
|
||||||
SurfaceStyle.Acrylic, SurfaceStyle.Glass -> onSurface
|
SurfaceRole.InverseBackground -> baseElevation
|
||||||
|
SurfaceRole.InverseAction -> baseElevation + 6.dp
|
||||||
}
|
}
|
||||||
val ambient = when (style) {
|
val resolvedContentColor = contentColor ?: when (role) {
|
||||||
SurfaceStyle.Watery -> Color(0xFF0E6FB0).copy(alpha = 0.38f)
|
SurfaceRole.PrimaryBackground -> scheme.onBackgroundPrimary
|
||||||
SurfaceStyle.Icy -> Color(0xFF3FA9E0).copy(alpha = 0.3f)
|
SurfaceRole.SecondaryBackground -> scheme.onBackgroundSecondary
|
||||||
SurfaceStyle.Chrome -> Color.Black.copy(alpha = 0.3f)
|
SurfaceRole.PrimaryAccent -> scheme.onAccentPrimary
|
||||||
SurfaceStyle.Acrylic -> onSurface.copy(alpha = 0.22f)
|
SurfaceRole.SecondaryAccent -> scheme.onAccentSecondary
|
||||||
SurfaceStyle.Glass -> onSurface.copy(alpha = 0.18f)
|
SurfaceRole.InverseBackground -> scheme.onBackgroundInverse
|
||||||
|
SurfaceRole.InverseAction -> scheme.onActionInverse
|
||||||
}
|
}
|
||||||
val spot = ambient.copy(alpha = (ambient.alpha + 0.08f).coerceAtMost(1f))
|
val ambient = when (role) {
|
||||||
|
SurfaceRole.PrimaryAccent -> scheme.accentPrimary.copy(alpha = 0.42f)
|
||||||
val borderBrush = when (style) {
|
SurfaceRole.SecondaryAccent -> scheme.accentSecondary.copy(alpha = 0.42f)
|
||||||
SurfaceStyle.Watery -> Brush.verticalGradient(
|
SurfaceRole.InverseAction -> scheme.actionInverse.copy(alpha = 0.42f)
|
||||||
listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)),
|
SurfaceRole.InverseBackground -> Color.Black.copy(alpha = 0.3f)
|
||||||
)
|
else -> palette.onSurface.copy(alpha = 0.2f)
|
||||||
|
|
||||||
SurfaceStyle.Icy -> Brush.verticalGradient(
|
|
||||||
listOf(Color.White.copy(alpha = 0.95f), Color.White.copy(alpha = 0.35f)),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Chrome -> Brush.verticalGradient(
|
|
||||||
listOf(Color.White.copy(alpha = 0.95f), Color.Black.copy(alpha = 0.28f)),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Acrylic -> Brush.verticalGradient(
|
|
||||||
listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.12f)),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Glass -> Brush.verticalGradient(
|
|
||||||
listOf(Color.White.copy(alpha = 0.9f), Color.White.copy(alpha = 0.3f)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
val spot = ambient.copy(alpha = (ambient.alpha + 0.07f).coerceAtMost(1f))
|
||||||
|
val borderBrush = roleBorderBrush(style, role, isLight, scheme)
|
||||||
|
|
||||||
// Translucent materials (Glass, Acrylic) must not use a drop shadow: it is drawn
|
// Translucent materials (Glass, Acrylic) must not use a drop shadow: it is drawn
|
||||||
// behind the node and, because the panel is see-through, shows up as a dark inset
|
// behind the node and, because the panel is see-through, shows up as a dark inset
|
||||||
// shape. Their depth comes from the edge, sheen and bevel instead.
|
// shape. Accent and secondary/inverse roles are filled, so they can cast one.
|
||||||
val castsShadow = style != SurfaceStyle.Glass && style != SurfaceStyle.Acrylic
|
val translucent = role == SurfaceRole.PrimaryBackground &&
|
||||||
|
(style == SurfaceStyle.Glass || style == SurfaceStyle.Acrylic)
|
||||||
|
|
||||||
CompositionLocalProvider(LocalContentColor provides resolvedContentColor) {
|
CompositionLocalProvider(LocalContentColor provides resolvedContentColor) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.then(
|
.then(
|
||||||
if (castsShadow) {
|
if (!translucent) {
|
||||||
Modifier.shadow(
|
Modifier.shadow(
|
||||||
elevation = resolvedElevation,
|
elevation = resolvedElevation,
|
||||||
shape = shape,
|
shape = shape,
|
||||||
@ -162,7 +186,7 @@ fun BaseSurface(
|
|||||||
)
|
)
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.drawWithCache {
|
.drawWithCache {
|
||||||
onDrawBehind { drawSurfaceMaterial(style, isLight) }
|
onDrawBehind { drawSurfaceMaterial(style, role, isLight, scheme) }
|
||||||
}
|
}
|
||||||
.border(BorderStroke(1.dp, borderBrush), shape)
|
.border(BorderStroke(1.dp, borderBrush), shape)
|
||||||
.padding(contentPadding),
|
.padding(contentPadding),
|
||||||
@ -173,32 +197,97 @@ fun BaseSurface(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paints the full-bleed material of [style] as a page background: the material body plus
|
* Paints the full-bleed material of [style] as a page background: the primary material
|
||||||
* its own sheen and reflections, but no panel bevel (a backdrop has no edges).
|
* body plus its sheen and reflections, but no panel bevel (a backdrop has no edges).
|
||||||
*/
|
*/
|
||||||
fun Modifier.baseSurfaceBackdrop(style: SurfaceStyle, isLight: Boolean): Modifier =
|
fun Modifier.baseSurfaceBackdrop(style: SurfaceStyle, isLight: Boolean): Modifier =
|
||||||
drawWithCache {
|
drawWithCache {
|
||||||
onDrawBehind { drawSurfaceBase(style, isLight) }
|
onDrawBehind {
|
||||||
|
drawStyleBase(style, isLight)
|
||||||
|
drawStyleDecorations(style, isLight)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawSurfaceMaterial(style: SurfaceStyle, isLight: Boolean) {
|
/**
|
||||||
drawSurfaceBase(style, isLight)
|
* Overlays a [SurfaceStyle]'s material character (gloss, bubbles, facets, specular band,
|
||||||
drawInnerBevel(isLight)
|
* grain) and its inner bevel on a node that already carries a base fill. Compact components
|
||||||
|
* — buttons, toggles, chips — use this to share the exact look of a [BaseSurface] in [role]
|
||||||
|
* without redrawing the fill (which comes from their own interaction-state brush).
|
||||||
|
*/
|
||||||
|
fun Modifier.baseSurfaceTexture(
|
||||||
|
style: SurfaceStyle,
|
||||||
|
role: SurfaceRole,
|
||||||
|
isLight: Boolean,
|
||||||
|
): Modifier = drawWithCache {
|
||||||
|
onDrawBehind {
|
||||||
|
val effectiveLight = when (role) {
|
||||||
|
SurfaceRole.InverseBackground, SurfaceRole.InverseAction -> !isLight
|
||||||
|
else -> isLight
|
||||||
|
}
|
||||||
|
drawStyleDecorations(style, effectiveLight)
|
||||||
|
drawInnerBevel(effectiveLight)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawSurfaceBase(style: SurfaceStyle, isLight: Boolean) {
|
/** Ambient-scheme overload of [baseSurfaceTexture] for a single role. */
|
||||||
|
@Composable
|
||||||
|
fun Modifier.baseSurfaceTexture(role: SurfaceRole): Modifier {
|
||||||
|
val scheme = LocalBaseUIColors.current
|
||||||
|
return baseSurfaceTexture(scheme.surfaceTheme, role, scheme.isLight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints the full [SurfaceStyle] material for [role] — base fill, material character and inner
|
||||||
|
* bevel — using the ambient [BaseUIColorScheme]. Use it to make a chrome panel (a sidebar, a
|
||||||
|
* player bar) a first-class surface rather than a flat Material surface.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.baseSurfaceMaterial(
|
||||||
|
role: SurfaceRole = SurfaceRole.PrimaryBackground,
|
||||||
|
style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme,
|
||||||
|
): Modifier {
|
||||||
|
val scheme = LocalBaseUIColors.current
|
||||||
|
return drawWithCache {
|
||||||
|
onDrawBehind { drawSurfaceMaterial(style, role, scheme.isLight, scheme) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DrawScope.drawSurfaceMaterial(
|
||||||
|
style: SurfaceStyle,
|
||||||
|
role: SurfaceRole,
|
||||||
|
isLight: Boolean,
|
||||||
|
scheme: BaseUIColorScheme,
|
||||||
|
) {
|
||||||
|
// Role base fill.
|
||||||
|
when (role) {
|
||||||
|
SurfaceRole.PrimaryBackground -> drawStyleBase(style, isLight)
|
||||||
|
|
||||||
|
// A distinct, complementary background hue (keeps the style's texture below).
|
||||||
|
SurfaceRole.SecondaryBackground -> drawRect(color = scheme.backgroundSecondary)
|
||||||
|
|
||||||
|
SurfaceRole.PrimaryAccent -> drawAccentBase(scheme.accentPrimary)
|
||||||
|
SurfaceRole.SecondaryAccent -> drawAccentBase(scheme.accentSecondary)
|
||||||
|
|
||||||
|
// Opaque inverted background so translucent styles still read as a solid surface.
|
||||||
|
SurfaceRole.InverseBackground -> drawRect(color = scheme.backgroundInverse)
|
||||||
|
|
||||||
|
SurfaceRole.InverseAction -> drawAccentBase(scheme.actionInverse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The material's own character (bubbles, facets, grain, specular band, sheen).
|
||||||
|
val effectiveLight = when (role) {
|
||||||
|
SurfaceRole.InverseBackground, SurfaceRole.InverseAction -> !isLight
|
||||||
|
else -> isLight
|
||||||
|
}
|
||||||
|
drawStyleDecorations(style, effectiveLight)
|
||||||
|
drawInnerBevel(effectiveLight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Material base gradients (one per style) ---------------------------------
|
||||||
|
|
||||||
|
private fun DrawScope.drawStyleBase(style: SurfaceStyle, isLight: Boolean) {
|
||||||
when (style) {
|
when (style) {
|
||||||
SurfaceStyle.Watery -> drawWatery(isLight)
|
SurfaceStyle.Glass -> {
|
||||||
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)
|
val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6)
|
||||||
drawRect(
|
drawRect(
|
||||||
brush = Brush.verticalGradient(
|
brush = Brush.verticalGradient(
|
||||||
@ -208,13 +297,9 @@ private fun DrawScope.drawGlass(isLight: Boolean) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
drawGloss(if (isLight) 0.55f else 0.16f)
|
|
||||||
drawBottomReflection(if (isLight) 0.4f else 0.1f)
|
|
||||||
drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawAcrylic(isLight: Boolean) {
|
SurfaceStyle.Acrylic -> {
|
||||||
// See-through acrylic: a translucent tinted sheet with a fine grain.
|
|
||||||
val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8)
|
val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8)
|
||||||
drawRect(
|
drawRect(
|
||||||
brush = Brush.verticalGradient(
|
brush = Brush.verticalGradient(
|
||||||
@ -224,7 +309,79 @@ private fun DrawScope.drawAcrylic(isLight: Boolean) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SurfaceStyle.Chrome -> {
|
||||||
|
if (isLight) {
|
||||||
|
drawRect(
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
0f to Color(0xFFF8FBFF),
|
||||||
|
0.16f to Color(0xFFD6DEE7),
|
||||||
|
0.4f to Color(0xFF8E99A8),
|
||||||
|
0.5f to Color(0xFFEDF2F7),
|
||||||
|
0.62f to Color(0xFF9AA5B4),
|
||||||
|
1f to Color(0xFF6C7787),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
drawRect(
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
0f to Color(0xFF3B424C),
|
||||||
|
0.16f to Color(0xFF2A303A),
|
||||||
|
0.4f to Color(0xFF1B2028),
|
||||||
|
0.5f to Color(0xFF343B45),
|
||||||
|
0.62f to Color(0xFF232830),
|
||||||
|
1f to Color(0xFF141820),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SurfaceStyle.Icy -> {
|
||||||
|
val colors = if (isLight) {
|
||||||
|
listOf(Color(0xFFF4FCFF), Color(0xFFCBE9FB), Color(0xFF9BD3F0))
|
||||||
|
} else {
|
||||||
|
listOf(Color(0xFF2A3B4D), Color(0xFF1B2A39), Color(0xFF0F1A26))
|
||||||
|
}
|
||||||
|
drawRect(brush = Brush.verticalGradient(colors))
|
||||||
|
}
|
||||||
|
|
||||||
|
SurfaceStyle.Watery -> {
|
||||||
|
// A clear-but-soft aqua column: unmistakably water, without a heavy flat blue.
|
||||||
|
val colors = if (isLight) {
|
||||||
|
listOf(Color(0xFFD8F0FD), Color(0xFFB0DEF7), Color(0xFF83C8EE))
|
||||||
|
} else {
|
||||||
|
listOf(Color(0xFF123249), Color(0xFF0B2135), Color(0xFF06141F))
|
||||||
|
}
|
||||||
|
drawRect(brush = Brush.verticalGradient(colors))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The accent-filled base used by [SurfaceRole.Action]. */
|
||||||
|
private fun DrawScope.drawAccentBase(accent: Color) {
|
||||||
|
drawRect(
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
listOf(
|
||||||
|
lerp(accent, Color.White, 0.45f),
|
||||||
|
accent,
|
||||||
|
lerp(accent, Color.Black, 0.16f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Material decorations (one per style) ------------------------------------
|
||||||
|
|
||||||
|
private fun DrawScope.drawStyleDecorations(style: SurfaceStyle, isLight: Boolean) {
|
||||||
|
when (style) {
|
||||||
|
SurfaceStyle.Glass -> {
|
||||||
|
drawGloss(if (isLight) 0.55f else 0.16f)
|
||||||
|
drawBottomReflection(if (isLight) 0.4f else 0.1f)
|
||||||
|
drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f)
|
||||||
|
}
|
||||||
|
|
||||||
|
SurfaceStyle.Acrylic -> {
|
||||||
// Fine grain so the panel reads as a translucent acrylic sheet.
|
// Fine grain so the panel reads as a translucent acrylic sheet.
|
||||||
val random = Random(23)
|
val random = Random(23)
|
||||||
repeat(180) {
|
repeat(180) {
|
||||||
@ -242,46 +399,18 @@ private fun DrawScope.drawAcrylic(isLight: Boolean) {
|
|||||||
drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f)
|
drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawChrome(isLight: Boolean) {
|
SurfaceStyle.Chrome -> {
|
||||||
if (isLight) {
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
0f to Color(0xFFF8FBFF),
|
|
||||||
0.16f to Color(0xFFD6DEE7),
|
|
||||||
0.4f to Color(0xFF8E99A8),
|
|
||||||
0.5f to Color(0xFFEDF2F7),
|
|
||||||
0.62f to Color(0xFF9AA5B4),
|
|
||||||
1f to Color(0xFF6C7787),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// Hard specular reflection near the top.
|
// Hard specular reflection near the top.
|
||||||
drawRect(
|
drawRect(
|
||||||
brush = Brush.verticalGradient(
|
brush = Brush.verticalGradient(
|
||||||
colors = listOf(Color.White.copy(alpha = 0.85f), Color.Transparent),
|
colors = listOf(
|
||||||
|
Color.White.copy(alpha = if (isLight) 0.85f else 0.18f),
|
||||||
|
Color.Transparent,
|
||||||
|
),
|
||||||
startY = size.height * 0.05f,
|
startY = size.height * 0.05f,
|
||||||
endY = size.height * 0.22f,
|
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.
|
// Darkened lower edge for the metal bevel.
|
||||||
drawRect(
|
drawRect(
|
||||||
brush = Brush.verticalGradient(
|
brush = Brush.verticalGradient(
|
||||||
@ -292,15 +421,7 @@ private fun DrawScope.drawChrome(isLight: Boolean) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawIcy(isLight: Boolean) {
|
SurfaceStyle.Icy -> {
|
||||||
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)
|
val frost = if (isLight) Color.White else Color(0xFFB8CCE0)
|
||||||
|
|
||||||
// Crystalline facets (kept inset so they are never clipped by the shape).
|
// Crystalline facets (kept inset so they are never clipped by the shape).
|
||||||
@ -329,43 +450,60 @@ private fun DrawScope.drawIcy(isLight: Boolean) {
|
|||||||
drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f)
|
drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawWatery(isLight: Boolean) {
|
SurfaceStyle.Watery -> {
|
||||||
val colors = if (isLight) {
|
val sheen = if (isLight) Color.White else Color(0xFFAFD3F2)
|
||||||
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 light pooling from the surface.
|
||||||
|
|
||||||
// Caustic / moonlight pooling from the surface.
|
|
||||||
drawRect(
|
drawRect(
|
||||||
brush = Brush.verticalGradient(
|
brush = Brush.verticalGradient(
|
||||||
colors = listOf(sheen.copy(alpha = if (isLight) 0.35f else 0.22f), Color.Transparent),
|
colors = listOf(
|
||||||
|
sheen.copy(alpha = if (isLight) 0.6f else 0.2f),
|
||||||
|
Color.Transparent,
|
||||||
|
),
|
||||||
startY = 0f,
|
startY = 0f,
|
||||||
endY = size.height * 0.5f,
|
endY = size.height * 0.55f,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val random = Random(53)
|
val random = Random(53)
|
||||||
repeat(5) {
|
// Refracted caustic blobs — the bright, rippling patterns of moving water.
|
||||||
val rx = size.width * (0.12f + random.nextFloat() * 0.2f)
|
repeat(9) {
|
||||||
|
val rx = size.minDimension * (0.14f + random.nextFloat() * 0.26f)
|
||||||
val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f)
|
val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f)
|
||||||
val cy = rx * 0.5f + random.nextFloat() * (size.height * 0.6f - rx).coerceAtLeast(0f)
|
val cy = rx + random.nextFloat() * (size.height - 2 * rx).coerceAtLeast(0f)
|
||||||
drawOval(
|
drawOval(
|
||||||
brush = Brush.radialGradient(
|
brush = Brush.radialGradient(
|
||||||
colors = listOf(sheen.copy(alpha = if (isLight) 0.3f else 0.18f), Color.Transparent),
|
colors = listOf(
|
||||||
|
sheen.copy(alpha = if (isLight) 0.5f else 0.22f),
|
||||||
|
Color.Transparent,
|
||||||
|
),
|
||||||
center = Offset(cx, cy),
|
center = Offset(cx, cy),
|
||||||
radius = rx,
|
radius = rx,
|
||||||
),
|
),
|
||||||
topLeft = Offset(cx - rx, cy - rx * 0.5f),
|
topLeft = Offset(cx - rx, cy - rx * 0.7f),
|
||||||
size = Size(rx * 2f, rx),
|
size = Size(rx * 2f, rx * 1.4f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
drawBubbles(seed = 71, count = 6, alpha = if (isLight) 0.4f else 0.22f)
|
// Ripple crests: thin horizontal highlights that read as surface waves.
|
||||||
|
repeat(3) {
|
||||||
|
val y = size.height * (0.18f + random.nextFloat() * 0.64f)
|
||||||
|
drawRect(
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
sheen.copy(alpha = if (isLight) 0.4f else 0.16f),
|
||||||
|
Color.Transparent,
|
||||||
|
),
|
||||||
|
startY = y - size.height * 0.035f,
|
||||||
|
endY = y + size.height * 0.035f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
drawBubbles(seed = 71, count = 11, alpha = if (isLight) 0.6f else 0.3f, rim = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawGloss(alpha: Float, heightFraction: Float = 0.5f) {
|
private fun DrawScope.drawGloss(alpha: Float, heightFraction: Float = 0.5f) {
|
||||||
@ -406,7 +544,7 @@ private fun DrawScope.drawInnerBevel(isLight: Boolean) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float) {
|
private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float, rim: Boolean = false) {
|
||||||
val random = Random(seed)
|
val random = Random(seed)
|
||||||
repeat(count) {
|
repeat(count) {
|
||||||
val radius = size.minDimension * (0.05f + random.nextFloat() * 0.14f)
|
val radius = size.minDimension * (0.05f + random.nextFloat() * 0.14f)
|
||||||
@ -423,59 +561,97 @@ private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float) {
|
|||||||
radius = radius,
|
radius = radius,
|
||||||
center = center,
|
center = center,
|
||||||
)
|
)
|
||||||
}
|
if (rim) {
|
||||||
}
|
drawCircle(
|
||||||
|
color = Color.White.copy(alpha = alpha * 0.7f),
|
||||||
@BaseUIPhonePreview
|
radius = radius,
|
||||||
@BaseUIDesktopPreview
|
center = center,
|
||||||
@Composable
|
style = Stroke(width = (radius * 0.14f).coerceAtLeast(0.8f)),
|
||||||
private fun BaseSurfacePreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(20.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
|
||||||
) {
|
|
||||||
SurfaceStyle.entries.forEach { style ->
|
|
||||||
BaseSurface(
|
|
||||||
style = style,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(84.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = style.name,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
private fun roleBorderBrush(
|
||||||
|
style: SurfaceStyle,
|
||||||
|
role: SurfaceRole,
|
||||||
|
isLight: Boolean,
|
||||||
|
scheme: BaseUIColorScheme,
|
||||||
|
): Brush = when (role) {
|
||||||
|
SurfaceRole.PrimaryBackground -> styleBorderBrush(style)
|
||||||
|
|
||||||
|
SurfaceRole.SecondaryBackground -> Brush.verticalGradient(
|
||||||
|
listOf(
|
||||||
|
lerp(scheme.backgroundSecondary, Color.White, 0.5f)
|
||||||
|
.copy(alpha = if (isLight) 0.85f else 0.6f),
|
||||||
|
Color.White.copy(alpha = 0.12f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
SurfaceRole.PrimaryAccent -> accentEdgeBrush(scheme.accentPrimary)
|
||||||
|
SurfaceRole.SecondaryAccent -> accentEdgeBrush(scheme.accentSecondary)
|
||||||
|
|
||||||
|
SurfaceRole.InverseBackground -> Brush.verticalGradient(
|
||||||
|
listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.1f)),
|
||||||
|
)
|
||||||
|
|
||||||
|
SurfaceRole.InverseAction -> accentEdgeBrush(scheme.actionInverse)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun accentEdgeBrush(accent: Color): Brush = Brush.verticalGradient(
|
||||||
|
listOf(lerp(accent, Color.White, 0.6f), lerp(accent, Color.Black, 0.2f)),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One preview showing everything about surfaces: every [SurfaceStyle] crossed with every
|
||||||
|
* [SurfaceRole], plus the full-bleed backdrops — no need to scroll through many previews.
|
||||||
|
*/
|
||||||
|
@BaseUIDesktopPreview
|
||||||
@Composable
|
@Composable
|
||||||
private fun BaseSurfaceGridPreview() {
|
private fun SurfacePreview() {
|
||||||
BaseUIPreview {
|
BaseUIPreview {
|
||||||
|
val colors = LocalBaseUIColors.current
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(20.dp),
|
.padding(20.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
SurfaceStyle.entries.chunked(2).forEach { rowStyles ->
|
SurfaceStyle.entries.forEach { style ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
rowStyles.forEach { style ->
|
SurfaceRole.entries.forEach { role ->
|
||||||
BaseSurface(
|
BaseSurface(
|
||||||
style = style,
|
style = style,
|
||||||
|
role = role,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(140.dp),
|
.height(84.dp),
|
||||||
|
contentPadding = PaddingValues(10.dp),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@ -483,54 +659,41 @@ private fun BaseSurfaceGridPreview() {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = style.name,
|
text = style.name,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Frutiger Aero",
|
text = role.name,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
repeat(2 - rowStyles.size) {
|
|
||||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
// Full-bleed backdrops (the page background material, without the panel bevel).
|
||||||
@BaseUIDesktopPreview
|
Row(
|
||||||
@Composable
|
modifier = Modifier.fillMaxWidth(),
|
||||||
private fun SurfaceThemeBackdropPreview() {
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
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 ->
|
SurfaceStyle.entries.forEach { style ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.weight(1f)
|
||||||
.height(66.dp)
|
.height(60.dp)
|
||||||
.clip(RoundedCornerShape(14.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.baseSurfaceBackdrop(style, isLight),
|
.baseSurfaceBackdrop(style, colors.isLight),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = style.name,
|
text = style.name,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
color = colors.onSurface,
|
||||||
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 background: Brush,
|
||||||
val foreground: Color,
|
val foreground: Color,
|
||||||
val highlight: Color,
|
val highlight: Color,
|
||||||
|
/**
|
||||||
|
* The [SurfaceRole] this colour represents. When set, components overlay the
|
||||||
|
* matching [SurfaceStyle] material (gloss, bubbles, facets, specular band, grain)
|
||||||
|
* on top of [background], so buttons and other actionable items share the exact
|
||||||
|
* look of a [BaseSurface]. `null` means no material (e.g. ghost buttons).
|
||||||
|
*/
|
||||||
|
val surfaceRole: SurfaceRole? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CardTheme(
|
data class CardTheme(
|
||||||
@ -132,6 +139,8 @@ data class BaseUITheme(
|
|||||||
val border: Border,
|
val border: Border,
|
||||||
val padding: PaddingValues,
|
val padding: PaddingValues,
|
||||||
val shadow: Shadow,
|
val shadow: Shadow,
|
||||||
|
/** The [SurfaceRole] the card renders, so it can overlay the matching material. */
|
||||||
|
val surfaceRole: SurfaceRole? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ListRowTheme(
|
data class ListRowTheme(
|
||||||
@ -232,16 +241,12 @@ fun rememberBaseUITheme(
|
|||||||
// highlight and translucent "glass" edges, lifted by soft, coloured
|
// highlight and translucent "glass" edges, lifted by soft, coloured
|
||||||
// shadows — the aqua/glass language of Frutiger-Aero.
|
// shadows — the aqua/glass language of Frutiger-Aero.
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
val glassTop = if (isLight) {
|
// Primary surface (subtle) with the style's palette, and the secondary surface
|
||||||
Color.White
|
// (slightly accented) used by cards and grouped content.
|
||||||
} else {
|
val panelTop = lerp(colors.backgroundPrimary, Color.White, if (isLight) 0.45f else 0.06f)
|
||||||
lerp(colors.surfaceContainerHigh, Color.White, 0.10f)
|
val panelBottom = colors.surfaceVariant
|
||||||
}
|
val secondaryTop = lerp(colors.backgroundSecondary, Color.White, if (isLight) 0.35f else 0.05f)
|
||||||
val glassBottom = if (isLight) {
|
val secondaryBottom = colors.backgroundSecondary
|
||||||
lerp(colors.surface, Color.White, 0.04f)
|
|
||||||
} else {
|
|
||||||
colors.surfaceContainer
|
|
||||||
}
|
|
||||||
val glassPressed = if (isLight) {
|
val glassPressed = if (isLight) {
|
||||||
lerp(colors.surfaceContainerHighest, Color.White, 0.06f)
|
lerp(colors.surfaceContainerHighest, Color.White, 0.06f)
|
||||||
} else {
|
} else {
|
||||||
@ -292,12 +297,12 @@ fun rememberBaseUITheme(
|
|||||||
// --- Glass (outline) -------------------------------------------------
|
// --- Glass (outline) -------------------------------------------------
|
||||||
val outlineColors = BaseUITheme.InteractionState(
|
val outlineColors = BaseUITheme.InteractionState(
|
||||||
normal = BaseUITheme.ButtonColors(
|
normal = BaseUITheme.ButtonColors(
|
||||||
background = glassBrush(glassTop, glassBottom),
|
background = glassBrush(panelTop, panelBottom),
|
||||||
foreground = colors.onSurface,
|
foreground = colors.onSurface,
|
||||||
highlight = gloss,
|
highlight = gloss,
|
||||||
),
|
),
|
||||||
hovered = BaseUITheme.ButtonColors(
|
hovered = BaseUITheme.ButtonColors(
|
||||||
background = glassBrush(glassTop, glassBottom),
|
background = glassBrush(panelTop, panelBottom),
|
||||||
foreground = colors.onSurface,
|
foreground = colors.onSurface,
|
||||||
highlight = Color.White.copy(alpha = if (isLight) 0.9f else 0.26f),
|
highlight = Color.White.copy(alpha = if (isLight) 0.9f else 0.26f),
|
||||||
),
|
),
|
||||||
@ -307,11 +312,11 @@ fun rememberBaseUITheme(
|
|||||||
highlight = glossSoft,
|
highlight = glossSoft,
|
||||||
),
|
),
|
||||||
focused = BaseUITheme.ButtonColors(
|
focused = BaseUITheme.ButtonColors(
|
||||||
background = glassBrush(glassTop, glassBottom),
|
background = glassBrush(panelTop, panelBottom),
|
||||||
foreground = colors.onSurface,
|
foreground = colors.onSurface,
|
||||||
highlight = gloss,
|
highlight = gloss,
|
||||||
),
|
),
|
||||||
)
|
).withRole(SurfaceRole.PrimaryBackground)
|
||||||
val glassShadow = BaseUITheme.InteractionState(
|
val glassShadow = BaseUITheme.InteractionState(
|
||||||
normal = BaseUITheme.Shadow(
|
normal = BaseUITheme.Shadow(
|
||||||
8.dp,
|
8.dp,
|
||||||
@ -348,10 +353,10 @@ fun rememberBaseUITheme(
|
|||||||
// --- Aqua accent (primary) -------------------------------------------
|
// --- Aqua accent (primary) -------------------------------------------
|
||||||
val primaryEdge = lerp(colors.primary, Color.White, 0.5f)
|
val primaryEdge = lerp(colors.primary, Color.White, 0.5f)
|
||||||
val primaryColors = BaseUITheme.InteractionState(
|
val primaryColors = BaseUITheme.InteractionState(
|
||||||
normal = BaseUITheme.ButtonColors(aqua(colors.primary), colors.onPrimary, gloss),
|
normal = BaseUITheme.ButtonColors(aqua(colors.accentPrimary), colors.onAccentPrimary, gloss),
|
||||||
hovered = BaseUITheme.ButtonColors(
|
hovered = BaseUITheme.ButtonColors(
|
||||||
aqua(colors.primary),
|
aqua(colors.accentPrimary),
|
||||||
colors.onPrimary,
|
colors.onAccentPrimary,
|
||||||
Color.White.copy(alpha = if (isLight) 0.9f else 0.3f),
|
Color.White.copy(alpha = if (isLight) 0.9f else 0.3f),
|
||||||
),
|
),
|
||||||
pressed = BaseUITheme.ButtonColors(
|
pressed = BaseUITheme.ButtonColors(
|
||||||
@ -359,11 +364,11 @@ fun rememberBaseUITheme(
|
|||||||
lerp(colors.primary, Color.Black, 0.16f),
|
lerp(colors.primary, Color.Black, 0.16f),
|
||||||
lerp(colors.primary, Color.Black, 0.24f),
|
lerp(colors.primary, Color.Black, 0.24f),
|
||||||
),
|
),
|
||||||
colors.onPrimary,
|
colors.onAccentPrimary,
|
||||||
glossSoft,
|
glossSoft,
|
||||||
),
|
),
|
||||||
focused = BaseUITheme.ButtonColors(aqua(colors.primary), colors.onPrimary, gloss),
|
focused = BaseUITheme.ButtonColors(aqua(colors.accentPrimary), colors.onAccentPrimary, gloss),
|
||||||
)
|
).withRole(SurfaceRole.PrimaryAccent)
|
||||||
val primaryShadow = BaseUITheme.InteractionState(
|
val primaryShadow = BaseUITheme.InteractionState(
|
||||||
normal = BaseUITheme.Shadow(
|
normal = BaseUITheme.Shadow(
|
||||||
10.dp,
|
10.dp,
|
||||||
@ -398,7 +403,7 @@ fun rememberBaseUITheme(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// --- Secondary (soft aqua glass) -------------------------------------
|
// --- Secondary (soft aqua glass) -------------------------------------
|
||||||
val secondaryBase = colors.secondaryContainer
|
val secondaryBase = colors.backgroundSecondary
|
||||||
val secondaryColors = BaseUITheme.InteractionState(
|
val secondaryColors = BaseUITheme.InteractionState(
|
||||||
normal = BaseUITheme.ButtonColors(
|
normal = BaseUITheme.ButtonColors(
|
||||||
glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase),
|
glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase),
|
||||||
@ -423,7 +428,7 @@ fun rememberBaseUITheme(
|
|||||||
colors.onSecondaryContainer,
|
colors.onSecondaryContainer,
|
||||||
gloss,
|
gloss,
|
||||||
),
|
),
|
||||||
)
|
).withRole(SurfaceRole.SecondaryBackground)
|
||||||
val secondaryShadow = BaseUITheme.InteractionState(
|
val secondaryShadow = BaseUITheme.InteractionState(
|
||||||
normal = BaseUITheme.Shadow(
|
normal = BaseUITheme.Shadow(
|
||||||
8.dp,
|
8.dp,
|
||||||
@ -592,10 +597,10 @@ fun rememberBaseUITheme(
|
|||||||
),
|
),
|
||||||
textField = TextFieldTheme(
|
textField = TextFieldTheme(
|
||||||
background = BaseUITheme.InteractionState(
|
background = BaseUITheme.InteractionState(
|
||||||
normal = glassBrush(glassTop, glassBottom),
|
normal = glassBrush(panelTop, panelBottom),
|
||||||
hovered = glassBrush(glassTop, glassBottom),
|
hovered = glassBrush(panelTop, panelBottom),
|
||||||
pressed = glassBrush(glassPressed, glassPressed),
|
pressed = glassBrush(glassPressed, glassPressed),
|
||||||
focused = glassBrush(glassTop, glassBottom),
|
focused = glassBrush(panelTop, panelBottom),
|
||||||
),
|
),
|
||||||
highlight = BaseUITheme.InteractionState(
|
highlight = BaseUITheme.InteractionState(
|
||||||
normal = gloss,
|
normal = gloss,
|
||||||
@ -647,7 +652,7 @@ fun rememberBaseUITheme(
|
|||||||
foreground = textFieldForeground,
|
foreground = textFieldForeground,
|
||||||
),
|
),
|
||||||
autoCompleteMenuTheme = AutoCompleteMenuTheme(
|
autoCompleteMenuTheme = AutoCompleteMenuTheme(
|
||||||
background = glassBrush(glassTop, glassBottom),
|
background = glassBrush(panelTop, panelBottom),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
border = BaseUITheme.Border(glassBorder, 1.dp),
|
border = BaseUITheme.Border(glassBorder, 1.dp),
|
||||||
padding = PaddingValues(0.dp),
|
padding = PaddingValues(0.dp),
|
||||||
@ -716,7 +721,7 @@ fun rememberBaseUITheme(
|
|||||||
border = glassBorderState,
|
border = glassBorderState,
|
||||||
padding = noPadding,
|
padding = noPadding,
|
||||||
),
|
),
|
||||||
checkmarkColor = colors.onPrimary,
|
checkmarkColor = colors.onAccentPrimary,
|
||||||
),
|
),
|
||||||
chipTab = ChipTabTheme(
|
chipTab = ChipTabTheme(
|
||||||
selected = BaseUITheme.ButtonStyle(
|
selected = BaseUITheme.ButtonStyle(
|
||||||
@ -783,7 +788,7 @@ fun rememberBaseUITheme(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
card = BaseUITheme.CardTheme(
|
card = BaseUITheme.CardTheme(
|
||||||
background = glassBrush(glassTop, glassBottom),
|
background = glassBrush(secondaryTop, secondaryBottom),
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(20.dp),
|
||||||
border = BaseUITheme.Border(glassBorder, 1.dp),
|
border = BaseUITheme.Border(glassBorder, 1.dp),
|
||||||
padding = PaddingValues(0.dp),
|
padding = PaddingValues(0.dp),
|
||||||
@ -793,20 +798,18 @@ fun rememberBaseUITheme(
|
|||||||
shadowColor.copy(alpha = 0.16f),
|
shadowColor.copy(alpha = 0.16f),
|
||||||
shadowColor.copy(alpha = 0.2f)
|
shadowColor.copy(alpha = 0.2f)
|
||||||
),
|
),
|
||||||
|
surfaceRole = SurfaceRole.SecondaryBackground,
|
||||||
),
|
),
|
||||||
listRowTile = BaseUITheme.ListRowTheme(
|
listRowTile = BaseUITheme.ListRowTheme(
|
||||||
background = BaseUITheme.AdvancedInteractionState(
|
background = BaseUITheme.AdvancedInteractionState(
|
||||||
normal = glassBrush(Color.Transparent, Color.Transparent),
|
normal = glassBrush(Color.Transparent, Color.Transparent),
|
||||||
hovered = glassBrush(glassTop, glassBottom),
|
hovered = glassBrush(panelTop, panelBottom),
|
||||||
pressed = glassBrush(glassPressed, glassPressed),
|
pressed = glassBrush(glassPressed, glassPressed),
|
||||||
focused = glassBrush(glassTop, glassBottom),
|
focused = glassBrush(panelTop, panelBottom),
|
||||||
selected = glassBrush(
|
selected = glassBrush(secondaryTop, secondaryBottom),
|
||||||
lerp(colors.primaryContainer, Color.White, 0.4f),
|
|
||||||
colors.primaryContainer,
|
|
||||||
),
|
|
||||||
disabled = glassBrush(
|
disabled = glassBrush(
|
||||||
glassTop.copy(alpha = 0.5f),
|
panelTop.copy(alpha = 0.5f),
|
||||||
glassBottom.copy(alpha = 0.5f),
|
panelBottom.copy(alpha = 0.5f),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
shape = BaseUITheme.AdvancedInteractionState(
|
shape = BaseUITheme.AdvancedInteractionState(
|
||||||
@ -857,7 +860,7 @@ fun rememberBaseUITheme(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
dialog = BaseUITheme.DialogTheme(
|
dialog = BaseUITheme.DialogTheme(
|
||||||
background = glassBrush(glassTop, glassBottom),
|
background = glassBrush(panelTop, panelBottom),
|
||||||
scrim = Color.Black.copy(alpha = 0.42f),
|
scrim = Color.Black.copy(alpha = 0.42f),
|
||||||
shape = RoundedCornerShape(24.dp),
|
shape = RoundedCornerShape(24.dp),
|
||||||
border = BaseUITheme.Border(glassEdge, 1.dp),
|
border = BaseUITheme.Border(glassEdge, 1.dp),
|
||||||
@ -870,7 +873,7 @@ fun rememberBaseUITheme(
|
|||||||
padding = PaddingValues(24.dp),
|
padding = PaddingValues(24.dp),
|
||||||
),
|
),
|
||||||
dropdownMenu = BaseUITheme.DropdownMenuTheme(
|
dropdownMenu = BaseUITheme.DropdownMenuTheme(
|
||||||
background = glassBrush(glassTop, glassBottom),
|
background = glassBrush(panelTop, panelBottom),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
border = BaseUITheme.Border(glassBorder, 1.dp),
|
border = BaseUITheme.Border(glassBorder, 1.dp),
|
||||||
shadow = BaseUITheme.Shadow(
|
shadow = BaseUITheme.Shadow(
|
||||||
@ -879,7 +882,7 @@ fun rememberBaseUITheme(
|
|||||||
shadowColor.copy(alpha = 0.22f),
|
shadowColor.copy(alpha = 0.22f),
|
||||||
shadowColor.copy(alpha = 0.28f)
|
shadowColor.copy(alpha = 0.28f)
|
||||||
),
|
),
|
||||||
itemHoverBackground = glassBrush(glassTop, glassBottom),
|
itemHoverBackground = glassBrush(panelTop, panelBottom),
|
||||||
itemForeground = colors.onSurface,
|
itemForeground = colors.onSurface,
|
||||||
itemHighlight = gloss,
|
itemHighlight = gloss,
|
||||||
padding = PaddingValues(6.dp),
|
padding = PaddingValues(6.dp),
|
||||||
@ -905,6 +908,16 @@ fun BaseUITheme.ButtonStyle.copyPadding(padding: PaddingValues): BaseUITheme.But
|
|||||||
fun BaseUITheme.ButtonStyle.copyColors(colors: BaseUITheme.ButtonColors): BaseUITheme.ButtonStyle =
|
fun BaseUITheme.ButtonStyle.copyColors(colors: BaseUITheme.ButtonColors): BaseUITheme.ButtonStyle =
|
||||||
copy(colors = BaseUITheme.InteractionState.fromSingleValue(colors))
|
copy(colors = BaseUITheme.InteractionState.fromSingleValue(colors))
|
||||||
|
|
||||||
|
/** Tags every interaction state of a button colour set with the surface role it renders. */
|
||||||
|
private fun BaseUITheme.InteractionState<BaseUITheme.ButtonColors>.withRole(
|
||||||
|
role: SurfaceRole,
|
||||||
|
): BaseUITheme.InteractionState<BaseUITheme.ButtonColors> = BaseUITheme.InteractionState(
|
||||||
|
normal = normal.copy(surfaceRole = role),
|
||||||
|
hovered = hovered.copy(surfaceRole = role),
|
||||||
|
pressed = pressed.copy(surfaceRole = role),
|
||||||
|
focused = focused.copy(surfaceRole = role),
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun invertedButtonStyle(): BaseUITheme.ButtonStyle {
|
fun invertedButtonStyle(): BaseUITheme.ButtonStyle {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|||||||
@ -184,8 +184,9 @@ fun Toggle(
|
|||||||
)
|
)
|
||||||
.clip(resolved.shape)
|
.clip(resolved.shape)
|
||||||
.background(resolved.colors.background, resolved.shape)
|
.background(resolved.colors.background, resolved.shape)
|
||||||
|
.applySurfaceMaterial(resolved.colors)
|
||||||
.border(BorderStroke(resolved.border.width, borderColor), resolved.shape)
|
.border(BorderStroke(resolved.border.width, borderColor), resolved.shape)
|
||||||
.highlight(resolved.colors.highlight)
|
.applyHighlight(resolved.colors)
|
||||||
.then(
|
.then(
|
||||||
if (onCheckedChange != null) {
|
if (onCheckedChange != null) {
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
@ -18,13 +18,25 @@
|
|||||||
package dev.krtirtho.spotube.core.ui.theming
|
package dev.krtirtho.spotube.core.ui.theming
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import com.materialkolor.rememberDynamicColorScheme
|
import com.materialkolor.rememberDynamicColorScheme
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.BaseUIColorScheme
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.baseUIColorScheme
|
||||||
import dev.krtirtho.spotube.modules.settings.Theme
|
import dev.krtirtho.spotube.modules.settings.Theme
|
||||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's single source of truth for colour.
|
||||||
|
*
|
||||||
|
* The selected [UserSettings.surfaceTheme] and [UserSettings.accentColor] produce a
|
||||||
|
* [BaseUIColorScheme], and every Material role is then overridden from it. That way the
|
||||||
|
* whole app — Material components included — renders in the chosen surface theme's colours
|
||||||
|
* and accent, instead of Material's dynamic palette drifting away from the base UI.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SpotubeTheme(
|
fun SpotubeTheme(
|
||||||
settings: UserSettings,
|
settings: UserSettings,
|
||||||
@ -35,6 +47,7 @@ fun SpotubeTheme(
|
|||||||
Theme.DARK -> true
|
Theme.DARK -> true
|
||||||
Theme.SYSTEM -> isSystemInDarkTheme()
|
Theme.SYSTEM -> isSystemInDarkTheme()
|
||||||
}
|
}
|
||||||
|
val isLight = !isDarkTheme
|
||||||
|
|
||||||
val accent = if (isDarkTheme) {
|
val accent = if (isDarkTheme) {
|
||||||
settings.accentColor.toDarkColor()
|
settings.accentColor.toDarkColor()
|
||||||
@ -42,15 +55,56 @@ fun SpotubeTheme(
|
|||||||
settings.accentColor.toLightColor()
|
settings.accentColor.toLightColor()
|
||||||
}
|
}
|
||||||
|
|
||||||
val colorScheme = rememberDynamicColorScheme(
|
val baseColors = remember(settings.surfaceTheme, isLight, accent) {
|
||||||
|
baseUIColorScheme(settings.surfaceTheme, isLight, accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dynamicScheme = rememberDynamicColorScheme(
|
||||||
seedColor = accent,
|
seedColor = accent,
|
||||||
isDark = isDarkTheme,
|
isDark = isDarkTheme,
|
||||||
)
|
)
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = dynamicScheme.mergeWithBaseColors(baseColors),
|
||||||
) {
|
) {
|
||||||
Surface(content = content)
|
Surface(content = content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides the accent/surface roles of a dynamically generated [ColorScheme] with the
|
||||||
|
* base UI palette, so Material components share the exact same colours as the base
|
||||||
|
* components and the surface theme's primary/secondary backgrounds.
|
||||||
|
*/
|
||||||
|
private fun ColorScheme.mergeWithBaseColors(base: BaseUIColorScheme): ColorScheme = copy(
|
||||||
|
primary = base.accentPrimary,
|
||||||
|
onPrimary = base.onAccentPrimary,
|
||||||
|
primaryContainer = base.primaryContainer,
|
||||||
|
onPrimaryContainer = base.onPrimaryContainer,
|
||||||
|
inversePrimary = base.actionInverse,
|
||||||
|
secondary = base.accentSecondary,
|
||||||
|
onSecondary = base.onAccentSecondary,
|
||||||
|
secondaryContainer = base.secondaryContainer,
|
||||||
|
onSecondaryContainer = base.onSecondaryContainer,
|
||||||
|
background = base.surface,
|
||||||
|
onBackground = base.onBackground,
|
||||||
|
surface = base.surface,
|
||||||
|
onSurface = base.onSurface,
|
||||||
|
surfaceVariant = base.surfaceVariant,
|
||||||
|
onSurfaceVariant = base.onSurfaceVariant,
|
||||||
|
surfaceTint = base.accentPrimary,
|
||||||
|
surfaceBright = base.surfaceContainerHigh,
|
||||||
|
surfaceDim = base.surfaceContainerHighest,
|
||||||
|
surfaceContainerLowest = base.surface,
|
||||||
|
surfaceContainerLow = base.surfaceContainer,
|
||||||
|
surfaceContainer = base.surfaceContainer,
|
||||||
|
surfaceContainerHigh = base.surfaceContainerHigh,
|
||||||
|
surfaceContainerHighest = base.surfaceContainerHighest,
|
||||||
|
inverseSurface = base.backgroundInverse,
|
||||||
|
inverseOnSurface = base.onBackgroundInverse,
|
||||||
|
outline = base.outline,
|
||||||
|
outlineVariant = base.outlineVariant,
|
||||||
|
error = base.error,
|
||||||
|
onError = base.onError,
|
||||||
|
scrim = base.scrim,
|
||||||
|
)
|
||||||
|
|||||||
@ -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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import compose.icons.FeatherIcons
|
import dev.krtirtho.spotube.core.ui.base.BaseSurface
|
||||||
import compose.icons.feathericons.Droplet
|
import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
|
||||||
import compose.icons.feathericons.Monitor
|
|
||||||
import spotube.composeapp.generated.resources.*
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.Radio
|
import dev.krtirtho.spotube.core.ui.base.SurfaceRole
|
||||||
import dev.krtirtho.spotube.core.ui.base.SurfaceStyle
|
import dev.krtirtho.spotube.core.ui.base.SurfaceStyle
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.baseUIColorScheme
|
||||||
import dev.krtirtho.spotube.modules.settings.AccentColors
|
import dev.krtirtho.spotube.modules.settings.AccentColors
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||||
import dev.krtirtho.spotube.modules.settings.Theme
|
import dev.krtirtho.spotube.modules.settings.Theme
|
||||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||||
|
import dev.krtirtho.spotube.modules.settings.colorFor
|
||||||
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
|
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
|
||||||
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
|
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
|
||||||
|
import dev.krtirtho.spotube.modules.settings.recommendedAccents
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxColorsSquare
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import spotube.composeapp.generated.resources.*
|
import spotube.composeapp.generated.resources.*
|
||||||
@ -97,39 +100,23 @@ internal fun LazyListScope.appearanceSection(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
SelectionSettingCard(
|
// Surface material and accent are one decision: each surface only offers the
|
||||||
title = stringResource(Res.string.settings_surface_theme_title),
|
// accents that are known to pair with it, so the result is always a curated,
|
||||||
subtitle = stringResource(
|
// good-looking combination instead of an arbitrary one.
|
||||||
Res.string.settings_surface_theme_subtitle_current,
|
AppearanceSettingCard(
|
||||||
settings.surfaceTheme.displayLabel()
|
selectedSurface = settings.surfaceTheme,
|
||||||
),
|
selectedAccent = settings.accentColor,
|
||||||
icon = {
|
icon = {
|
||||||
SettingsItemIcon(
|
SettingsItemIcon(
|
||||||
Iconsax.IconsaxMagic,
|
Iconsax.IconsaxMagic,
|
||||||
stringResource(Res.string.settings_surface_theme_title)
|
stringResource(Res.string.settings_appearance_title)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
selectedOption = settings.surfaceTheme,
|
onSaved = { surface, accent ->
|
||||||
options = SurfaceStyle.entries,
|
|
||||||
optionLabel = { it.displayLabel() },
|
|
||||||
onOptionSelected = { surfaceTheme ->
|
|
||||||
settingsViewModel.updateSettings {
|
settingsViewModel.updateSettings {
|
||||||
copy(surfaceTheme = surfaceTheme)
|
copy(surfaceTheme = surface, accentColor = accent)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
|
||||||
AccentColorSettingCard(
|
|
||||||
selectedAccent = settings.accentColor,
|
|
||||||
icon = {
|
|
||||||
SettingsItemIcon(Iconsax.IconsaxColorsSquare, stringResource(Res.string.settings_accent_title))
|
|
||||||
},
|
|
||||||
onColorSaved = { accent ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(accentColor = accent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -170,43 +157,53 @@ private fun AccentColors.displayLabel(): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AccentColorSettingCard(
|
private fun AppearanceSettingCard(
|
||||||
|
selectedSurface: SurfaceStyle,
|
||||||
selectedAccent: AccentColors,
|
selectedAccent: AccentColors,
|
||||||
icon: (@Composable () -> Unit)? = null,
|
icon: (@Composable () -> Unit)? = null,
|
||||||
onColorSaved: (AccentColors) -> Unit,
|
onSaved: (SurfaceStyle, AccentColors) -> Unit,
|
||||||
) {
|
) {
|
||||||
var isDialogOpen by remember { mutableStateOf(false) }
|
var isDialogOpen by remember { mutableStateOf(false) }
|
||||||
|
val isLight = LocalBaseUIColors.current.isLight
|
||||||
|
|
||||||
SettingCardItem(
|
SettingCardItem(
|
||||||
title = stringResource(Res.string.settings_accent_title),
|
title = stringResource(Res.string.settings_appearance_title),
|
||||||
subtitle = stringResource(
|
subtitle = stringResource(
|
||||||
Res.string.settings_accent_subtitle_current,
|
Res.string.settings_appearance_subtitle_current,
|
||||||
selectedAccent.displayLabel()
|
selectedSurface.displayLabel(),
|
||||||
|
selectedAccent.displayLabel(),
|
||||||
),
|
),
|
||||||
icon = icon,
|
icon = icon,
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
AccentDualPreview(
|
AppearanceTrailingSwatch(
|
||||||
lightAccent = selectedAccent.toLightColor(),
|
surface = selectedSurface,
|
||||||
darkAccent = selectedAccent.toDarkColor(),
|
accent = selectedAccent,
|
||||||
|
isLight = isLight,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = { isDialogOpen = true },
|
||||||
isDialogOpen = true
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isDialogOpen) {
|
if (isDialogOpen) {
|
||||||
var draftAccent by remember(selectedAccent, isDialogOpen) { mutableStateOf(selectedAccent) }
|
var draftSurface by remember(selectedSurface, isDialogOpen) {
|
||||||
|
mutableStateOf(selectedSurface)
|
||||||
|
}
|
||||||
|
var draftAccent by remember(selectedSurface, selectedAccent, isDialogOpen) {
|
||||||
|
mutableStateOf(selectedAccent)
|
||||||
|
}
|
||||||
|
|
||||||
ThemedDialog(
|
ThemedDialog(
|
||||||
onDismissRequest = { isDialogOpen = false },
|
onDismissRequest = { isDialogOpen = false },
|
||||||
title = {
|
title = {
|
||||||
Text(stringResource(Res.string.settings_accent_dialog_title), style = MaterialTheme.typography.titleLarge)
|
Text(
|
||||||
|
stringResource(Res.string.settings_appearance_dialog_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
onColorSaved(draftAccent)
|
onSaved(draftSurface, draftAccent)
|
||||||
isDialogOpen = false
|
isDialogOpen = false
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
@ -217,141 +214,245 @@ private fun AccentColorSettingCard(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(Res.string.settings_accent_dialog_description),
|
text = stringResource(Res.string.settings_appearance_dialog_description),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.settings_appearance_surface_label),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
AccentThemePreview(
|
SurfaceStyle.entries.forEach { style ->
|
||||||
title = stringResource(Res.string.settings_preview_light),
|
SurfaceStyleTile(
|
||||||
accent = draftAccent.toLightColor(),
|
style = style,
|
||||||
background = Color(0xFFFFFFFF),
|
accent = draftAccent,
|
||||||
textColor = Color(0xFF121212),
|
isLight = isLight,
|
||||||
modifier = Modifier.weight(1f)
|
selected = style == draftSurface,
|
||||||
)
|
modifier = Modifier.weight(1f),
|
||||||
AccentThemePreview(
|
onClick = {
|
||||||
title = stringResource(Res.string.settings_preview_dark),
|
draftSurface = style
|
||||||
accent = draftAccent.toDarkColor(),
|
if (draftAccent !in style.recommendedAccents) {
|
||||||
background = Color(0xFF121212),
|
draftAccent = style.recommendedAccents.first()
|
||||||
textColor = Color(0xFFEDEDED),
|
}
|
||||||
modifier = Modifier.weight(1f)
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
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(
|
||||||
text = option.displayLabel(),
|
text = stringResource(Res.string.settings_appearance_accent_label),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
)
|
||||||
AccentDualPreview(
|
Row(
|
||||||
lightAccent = option.toLightColor(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
darkAccent = option.toDarkColor(),
|
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
|
@Composable
|
||||||
private fun AccentDualPreview(
|
private fun SurfaceStyleTile(
|
||||||
lightAccent: Color,
|
style: SurfaceStyle,
|
||||||
darkAccent: Color,
|
accent: AccentColors,
|
||||||
) {
|
isLight: Boolean,
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
selected: Boolean,
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(20.dp)
|
|
||||||
.background(Color.White, RoundedCornerShape(6.dp))
|
|
||||||
.border(1.dp, Color(0x22000000), RoundedCornerShape(6.dp)),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(10.dp)
|
|
||||||
.background(lightAccent, CircleShape)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(20.dp)
|
|
||||||
.background(Color(0xFF121212), RoundedCornerShape(6.dp))
|
|
||||||
.border(1.dp, Color(0x33FFFFFF), RoundedCornerShape(6.dp)),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(10.dp)
|
|
||||||
.background(darkAccent, CircleShape)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AccentThemePreview(
|
|
||||||
title: String,
|
|
||||||
accent: Color,
|
|
||||||
background: Color,
|
|
||||||
textColor: Color,
|
|
||||||
modifier: Modifier = Modifier,
|
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(
|
Column(
|
||||||
modifier = modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.background(background, RoundedCornerShape(10.dp))
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
.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)
|
Text(
|
||||||
Box(
|
text = style.displayLabel(),
|
||||||
modifier = Modifier
|
style = MaterialTheme.typography.labelMedium,
|
||||||
.fillMaxWidth()
|
|
||||||
.height(6.dp)
|
|
||||||
.background(accent, RoundedCornerShape(999.dp))
|
|
||||||
)
|
)
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(14.dp)
|
.size(14.dp)
|
||||||
.background(accent, CircleShape)
|
.background(accent.colorFor(isLight), CircleShape),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AccentSwatch(
|
||||||
|
accent: AccentColors,
|
||||||
|
label: String,
|
||||||
|
isLight: Boolean,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(34.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(accent.colorFor(isLight))
|
||||||
|
.border(
|
||||||
|
width = if (selected) 3.dp else 1.dp,
|
||||||
|
color = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outlineVariant
|
||||||
|
},
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.semantics { contentDescription = label },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppearanceTrailingSwatch(
|
||||||
|
surface: SurfaceStyle,
|
||||||
|
accent: AccentColors,
|
||||||
|
isLight: Boolean,
|
||||||
|
) {
|
||||||
|
val scheme = remember(surface, accent, isLight) {
|
||||||
|
baseUIColorScheme(surface, isLight, accent.colorFor(isLight))
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(LocalBaseUIColors provides scheme) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
BaseSurface(
|
||||||
|
style = surface,
|
||||||
|
role = SurfaceRole.PrimaryBackground,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.size(width = 46.dp, height = 28.dp),
|
||||||
|
) {}
|
||||||
|
BaseSurface(
|
||||||
|
style = surface,
|
||||||
|
role = SurfaceRole.PrimaryAccent,
|
||||||
|
shape = CircleShape,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppearancePreview(
|
||||||
|
surface: SurfaceStyle,
|
||||||
|
accent: AccentColors,
|
||||||
|
isLight: Boolean,
|
||||||
|
) {
|
||||||
|
val scheme = remember(surface, accent, isLight) {
|
||||||
|
baseUIColorScheme(surface, isLight, accent.colorFor(isLight))
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(LocalBaseUIColors provides scheme) {
|
||||||
|
BaseSurface(
|
||||||
|
style = surface,
|
||||||
|
role = SurfaceRole.PrimaryBackground,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(150.dp),
|
||||||
|
contentPadding = PaddingValues(14.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(Res.string.settings_preview_label),
|
text = stringResource(Res.string.settings_preview_label),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = textColor
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
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(
|
Text(
|
||||||
stringResource(Res.string.settings_preview_button),
|
stringResource(Res.string.settings_preview_button),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = accent
|
)
|
||||||
|
}
|
||||||
|
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.NavigationState
|
||||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.BaseSurface
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.SurfaceRole
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -55,12 +57,10 @@ fun AppBottombar(
|
|||||||
navigationState: NavigationState,
|
navigationState: NavigationState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Surface(
|
BaseSurface(
|
||||||
modifier = modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars),
|
role = SurfaceRole.PrimaryBackground,
|
||||||
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
||||||
color = MaterialTheme.colorScheme.surface,
|
modifier = modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars),
|
||||||
tonalElevation = 3.dp,
|
|
||||||
shadowElevation = 10.dp,
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@ -36,7 +36,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@ -70,8 +69,10 @@ import org.koin.core.parameter.parametersOf
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
|
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.BaseSurface
|
||||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.SurfaceRole
|
||||||
|
|
||||||
// The floating player on small screens that appears above the floating AppBottombar
|
// The floating player on small screens that appears above the floating AppBottombar
|
||||||
//
|
//
|
||||||
@ -115,14 +116,12 @@ fun AppFloatingPlayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
BaseSurface(
|
||||||
|
role = SurfaceRole.InverseBackground,
|
||||||
|
shape = RoundedCornerShape(24.dp, 24.dp),
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(76.dp),
|
.height(76.dp),
|
||||||
shape = RoundedCornerShape(24.dp, 24.dp),
|
|
||||||
color = MaterialTheme.colorScheme.inverseSurface,
|
|
||||||
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
|
|
||||||
tonalElevation = 3.dp,
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@ -72,8 +72,10 @@ import dev.krtirtho.spotube.core.navigation.Routes
|
|||||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.Slider
|
import dev.krtirtho.spotube.core.ui.base.Slider
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.SurfaceRole
|
||||||
import dev.krtirtho.spotube.core.ui.base.VariableIconButton
|
import dev.krtirtho.spotube.core.ui.base.VariableIconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.VariableIconButtonVariant
|
import dev.krtirtho.spotube.core.ui.base.VariableIconButtonVariant
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.baseSurfaceTexture
|
||||||
import dev.krtirtho.spotube.core.ui.base.copyShape
|
import dev.krtirtho.spotube.core.ui.base.copyShape
|
||||||
import dev.krtirtho.spotube.core.ui.base.invertedButtonStyle
|
import dev.krtirtho.spotube.core.ui.base.invertedButtonStyle
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon
|
import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon
|
||||||
@ -210,7 +212,9 @@ fun AppLargePlayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
|
// A translucent tint (not near-opaque) so the blurred art behind still reads through, and
|
||||||
|
// the material's watery caustics sit on top of it.
|
||||||
|
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@ -234,6 +238,7 @@ fun AppLargePlayer(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.baseSurfaceTexture(SurfaceRole.PrimaryBackground)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@ -55,6 +55,8 @@ import dev.krtirtho.spotube.core.navigation.NavigationState
|
|||||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.SurfaceRole
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.baseSurfaceMaterial
|
||||||
import dev.krtirtho.spotube.core.ui.component.SidebarItem
|
import dev.krtirtho.spotube.core.ui.component.SidebarItem
|
||||||
import dev.krtirtho.spotube.core.ui.component.SidebarSectionLabel
|
import dev.krtirtho.spotube.core.ui.component.SidebarSectionLabel
|
||||||
import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar
|
import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar
|
||||||
@ -88,7 +90,8 @@ fun AppSidebar(
|
|||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.width(width),
|
.width(width)
|
||||||
|
.baseSurfaceMaterial(role = SurfaceRole.PrimaryBackground),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user