intro: pick the car's button style, with sample buttons that redraw as you choose

This commit is contained in:
Seb3thehacker committed 2026-10-13 01:14:06 -07:00
1 parent 01a41c01c8
commit b3f2088a1d
1 file changed
+96 -12
@@ -75,6 +75,17 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.CompositionLocalProvider
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.theme.GsButton
import app.seb3thehacker.gearslip.car.theme.GsIconButton
import app.seb3thehacker.gearslip.car.theme.GsThemes
import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
import app.seb3thehacker.gearslip.speech.SpeechModels import app.seb3thehacker.gearslip.speech.SpeechModels
import app.seb3thehacker.gearslip.stats.UsageStats import app.seb3thehacker.gearslip.stats.UsageStats
import kotlin.math.PI import kotlin.math.PI
@@ -106,14 +117,14 @@ object Intro {
} }
/** The intro's tones, taken from the phone's own Material You colours like the rest of the app. */ /** The intro's tones, taken from the phone's own Material You colours like the rest of the app. */
private class IntroColors( internal class IntroColors(
val surface: Color, val onSurface: Color, val onVariant: Color, val outline: Color, val surface: Color, val onSurface: Color, val onVariant: Color, val outline: Color,
val primary: Color, val onPrimary: Color, val primaryC: Color, val onPrimaryC: Color, val primary: Color, val onPrimary: Color, val primaryC: Color, val onPrimaryC: Color,
val tertiaryC: Color, val onTertiaryC: Color, val secondaryC: Color, val onSecondaryC: Color, val tertiaryC: Color, val onTertiaryC: Color, val secondaryC: Color, val onSecondaryC: Color,
) )
@Composable @Composable
private fun introColors(): IntroColors { internal fun introColors(): IntroColors {
val m = MaterialTheme.colorScheme val m = MaterialTheme.colorScheme
return IntroColors( return IntroColors(
surface = m.surface, onSurface = m.onSurface, onVariant = m.onSurfaceVariant, outline = m.outline, surface = m.surface, onSurface = m.onSurface, onVariant = m.onSurfaceVariant, outline = m.outline,
@@ -123,7 +134,7 @@ private fun introColors(): IntroColors {
) )
} }
private enum class IntroStep { WELCOME, WIRELESS, KNOBS, VOICE, GRAPHENE, THANKS, APPS, DRIVE } private enum class IntroStep { WELCOME, WIRELESS, KNOBS, VOICE, GRAPHENE, THANKS, APPS, THEME, DRIVE }
private val Pop = CubicBezierEasing(0.3f, 1.6f, 0.5f, 1f) private val Pop = CubicBezierEasing(0.3f, 1.6f, 0.5f, 1f)
private val Rise = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f) private val Rise = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f)
@@ -183,6 +194,9 @@ fun IntroScreen(onDone: () -> Unit) {
IntroStep.APPS -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Good company.", IntroStep.APPS -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Good company.",
"Free, open-source apps that work well in the car. Tap one to see it on GitHub.", step, "Free, open-source apps that work well in the car. Tap one to see it on GitHub.", step,
extra = { AppPicks(c, ::cq, ::cqSp, step) }) { StarIcon(c.onSecondaryC) } extra = { AppPicks(c, ::cq, ::cqSp, step) }) { StarIcon(c.onSecondaryC) }
IntroStep.THEME -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Your buttons.",
"Pick how the car's buttons look. Change it any time in the car's settings.", step,
extra = { ThemePicker(c, ::cq, ::cqSp, step) }) { ButtonsIcon(c.onTertiaryC) }
IntroStep.DRIVE -> BadgeStep(c, ::cq, ::cqSp, c.primaryC, "Let's drive.", IntroStep.DRIVE -> BadgeStep(c, ::cq, ::cqSp, c.primaryC, "Let's drive.",
"That's ${Intro.release}. Plug in, or just get in.", step, "That's ${Intro.release}. Plug in, or just get in.", step,
action = "Start" to finish) { CheckIcon(c.onPrimaryC) } action = "Start" to finish) { CheckIcon(c.onPrimaryC) }
@@ -195,7 +209,7 @@ fun IntroScreen(onDone: () -> Unit) {
/** 0 to 1 once each time [key] changes, after [delayMs]: drives the entrance of one element. */ /** 0 to 1 once each time [key] changes, after [delayMs]: drives the entrance of one element. */
@Composable @Composable
private fun entrance(key: Any, delayMs: Int, durationMs: Int = 650): Float { internal fun entrance(key: Any, delayMs: Int, durationMs: Int = 650): Float {
val a = remember(key) { Animatable(0f) } val a = remember(key) { Animatable(0f) }
LaunchedEffect(key) { LaunchedEffect(key) {
delay(delayMs.toLong()) delay(delayMs.toLong())
@@ -205,14 +219,14 @@ private fun entrance(key: Any, delayMs: Int, durationMs: Int = 650): Float {
} }
/** Slides up and fades in, like the mockup's `up`. */ /** Slides up and fades in, like the mockup's `up`. */
private fun Modifier.up(p: Float, distance: Dp) = graphicsLayer { internal fun Modifier.up(p: Float, distance: Dp) = graphicsLayer {
val e = Rise.transform(p) val e = Rise.transform(p)
alpha = e alpha = e
translationY = (1 - e) * distance.toPx() translationY = (1 - e) * distance.toPx()
} }
/** Grows in with a bounce, like the mockup's `bounce`. */ /** Grows in with a bounce, like the mockup's `bounce`. */
private fun Modifier.pop(p: Float) = graphicsLayer { internal fun Modifier.pop(p: Float) = graphicsLayer {
val e = Pop.transform(p) val e = Pop.transform(p)
alpha = p.coerceAtMost(1f) alpha = p.coerceAtMost(1f)
scaleX = 0.5f + 0.5f * e scaleX = 0.5f + 0.5f * e
@@ -501,9 +515,79 @@ private fun AppPicks(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUni
} }
} }
/**
* The car's button styles side by side, each drawn in its own style, and a row of real car
* controls below that redraws in whichever is picked. Picking saves it, so the car shows it too.
*/
@Composable
private fun ThemePicker(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
val chosenId by CarSettings.uiTheme.collectAsState()
val chosen = GsThemes.byId(chosenId)
// Flat first: it's the default.
val themes = listOf(GsThemes.Flat) + GsThemes.builtIn.filter { it != GsThemes.Flat }
Column(
Modifier.padding(top = cq(7f)).fillMaxWidth().up(entrance(key, 500, 700), cq(7f)),
verticalArrangement = Arrangement.spacedBy(cq(6f)),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(cq(3f))) {
themes.forEach { theme ->
CompositionLocalProvider(LocalGsTheme provides theme) {
GsButton(
onClick = { CarSettings.setUiTheme(theme.id) },
modifier = Modifier.weight(1f),
tone = if (theme == chosen) GsTone.Primary else GsTone.Neutral,
contentPadding = PaddingValues(horizontal = cq(1f), vertical = cq(3f)),
) {
Text(
if (theme == chosen) "✓ ${theme.name}" else theme.name,
style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.Bold),
maxLines = 1,
)
}
}
}
}
// What the car shows: the player's controls and a full-width action.
CompositionLocalProvider(LocalGsTheme provides chosen) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(cq(3f)),
verticalAlignment = Alignment.CenterVertically,
) {
GsIconButton(Icons.Filled.Search, "Search", onClick = {}, size = cq(15f), iconSize = cq(7f))
GsIconButton(Icons.Filled.PlayArrow, "Play", onClick = {}, tone = GsTone.Primary, size = cq(15f), iconSize = cq(8f))
GsButton(
onClick = {},
modifier = Modifier.weight(1f).height(cq(15f)),
tone = GsTone.Tonal,
) { Text("Navigate", style = TextStyle(fontSize = cqSp(4.6f), fontWeight = FontWeight.Bold)) }
}
}
}
}
/** Three keys of a keypad, pressing one after another. */
@Composable
private fun ButtonsIcon(color: Color) {
val t by rememberInfiniteTransition(label = "keys").animateFloat(
0f, 3f, infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "keys",
)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
listOf(6f, 19f, 32f).forEachIndexed { i, x ->
// Each key dips for a moment while its turn comes round.
val dip = (1f - kotlin.math.abs(t - i - 0.5f) * 2f).coerceIn(0f, 1f) * 4f
drawRoundRect(color.copy(alpha = 0.45f), Offset(x, 16f + 4f), Size(10f, 16f),
androidx.compose.ui.geometry.CornerRadius(3f))
drawRoundRect(color, Offset(x, 16f + dip), Size(10f, 16f), androidx.compose.ui.geometry.CornerRadius(3f))
}
}
}
}
/** Material's scalloped "cookie": a circle whose radius waves around its edge. */ /** Material's scalloped "cookie": a circle whose radius waves around its edge. */
@Composable @Composable
private fun Cookie(color: Color, modifier: Modifier) { internal fun Cookie(color: Color, modifier: Modifier) {
Canvas(modifier) { Canvas(modifier) {
val unit = size.minDimension / 100f val unit = size.minDimension / 100f
val path = Path() val path = Path()
@@ -578,7 +662,7 @@ private fun DialIcon(color: Color) {
} }
@Composable @Composable
private fun MicIcon(color: Color) { internal fun MicIcon(color: Color) {
val t = rememberInfiniteTransition(label = "mic") val t = rememberInfiniteTransition(label = "mic")
val waves = (0..1).map { i -> val waves = (0..1).map { i ->
t.animateFloat( t.animateFloat(
@@ -605,7 +689,7 @@ private fun MicIcon(color: Color) {
} }
@Composable @Composable
private fun StarIcon(color: Color) { internal fun StarIcon(color: Color) {
val turn by rememberInfiniteTransition(label = "star").animateFloat( val turn by rememberInfiniteTransition(label = "star").animateFloat(
-8f, 8f, infiniteRepeatable(tween(1600, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "star", -8f, 8f, infiniteRepeatable(tween(1600, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "star",
) )
@@ -626,7 +710,7 @@ private fun StarIcon(color: Color) {
} }
@Composable @Composable
private fun ShieldIcon(color: Color, tick: Color) { internal fun ShieldIcon(color: Color, tick: Color) {
val p = entrance(IntroStep.GRAPHENE, 800, 500) val p = entrance(IntroStep.GRAPHENE, 800, 500)
Canvas(Modifier.fillMaxSize()) { Canvas(Modifier.fillMaxSize()) {
inIconBox { inIconBox {
@@ -643,7 +727,7 @@ private fun ShieldIcon(color: Color, tick: Color) {
} }
@Composable @Composable
private fun HeartIcon(color: Color) { internal fun HeartIcon(color: Color) {
val beat by rememberInfiniteTransition(label = "beat").animateFloat( val beat by rememberInfiniteTransition(label = "beat").animateFloat(
1f, 1f, 1f, 1f,
infiniteRepeatable(keyframes { infiniteRepeatable(keyframes {
@@ -696,7 +780,7 @@ private fun DrawScope.drawPartialStroke(points: List<Offset>, progress: Float, c
} }
@Composable @Composable
private fun Dots(c: IntroColors, count: Int, current: Int, cq: (Float) -> Dp, modifier: Modifier) { internal fun Dots(c: IntroColors, count: Int, current: Int, cq: (Float) -> Dp, modifier: Modifier) {
Row(modifier, horizontalArrangement = Arrangement.spacedBy(cq(2f))) { Row(modifier, horizontalArrangement = Arrangement.spacedBy(cq(2f))) {
repeat(count) { i -> repeat(count) { i ->
val on = i == current val on = i == current