release announcement removed

This commit is contained in:
Seb3thehacker committed 2026-10-24 18:59:30 -07:00
1 parent 2fcd586e67
commit dc287a7fa2
3 files changed
+341 -818

No files matched your search

@@ -37,7 +37,7 @@ import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.CertProvider
import app.seb3thehacker.gearslip.stats.UsageStats
private enum class Screen { INTRO, HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, USAGE_NOTES, HELP, WHATS_NEW, RECOMMENDED_APPS, CAR_GROUP }
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, USAGE_NOTES, HELP, WHATS_NEW, RECOMMENDED_APPS, CAR_GROUP }
/** Three screens and a back stack of depth one: no navigation library needed. */
@Composable
@@ -47,8 +47,8 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
!AppSettings.hasSkippedCertSetup(context) && !CertProvider.hasAnyCert(context)
}
val needsSetupGuide = remember { !AppSettings.hasSeenPermissionsSetup(context) }
// Where launch goes once the intro (if any) is done.
val afterIntro = remember {
// Where launch goes: setup until it's done, then Home.
val start = remember {
when {
needsCertSetup -> Screen.CERT_SETUP
needsSetupGuide -> Screen.SETUP_GUIDE
@@ -57,16 +57,14 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
else -> Screen.HOME
}
}
var screen by rememberSaveable { mutableStateOf(if (Intro.due(context)) Screen.INTRO else afterIntro) }
// A replay from About goes back to Settings; the launch one carries on into setup.
var introReturn by rememberSaveable { mutableStateOf(afterIntro) }
var screen by rememberSaveable { mutableStateOf(start) }
// Home's "it worked" card opens Settings at usage notes rather than at the top.
var settingsAtUsageNotes by rememberSaveable { mutableStateOf(false) }
// What's new and Connection help open from Home and from Settings; back returns to whichever.
var backTo by rememberSaveable { mutableStateOf(Screen.SETTINGS) }
// Which of the car's setting groups Home opened.
var carGroup by rememberSaveable { mutableStateOf(CarGroup.SCREEN) }
BackHandler(enabled = screen != Screen.HOME && screen != Screen.INTRO) {
BackHandler(enabled = screen != Screen.HOME) {
screen = when (screen) {
Screen.RECOMMENDED_APPS, Screen.LOGS, Screen.CAR_PREVIEW -> Screen.SETTINGS
Screen.HELP, Screen.WHATS_NEW -> backTo
@@ -103,11 +101,10 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
.windowInsetsPadding(WindowInsets.statusBars)
.consumeWindowInsets(WindowInsets.statusBars),
) {
if (BuildConfig.DEBUG && screen != Screen.CAR_PREVIEW && screen != Screen.INTRO) DebugBuildBanner()
if (BuildConfig.DEBUG && screen != Screen.CAR_PREVIEW) DebugBuildBanner()
Column(Modifier.weight(1f)) {
when (screen) {
Screen.INTRO -> IntroScreen(onDone = { screen = introReturn })
Screen.HOME -> HomeScreen(
onOpenSettings = { settingsAtUsageNotes = false; screen = Screen.SETTINGS },
onOpenHelp = { backTo = Screen.HOME; screen = Screen.HELP },
@@ -0,0 +1,335 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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 kotlin.math.PI
import kotlin.math.cos
import kotlin.math.atan2
import kotlin.math.sqrt
import kotlin.math.sin
import kotlinx.coroutines.delay
/**
* The setup guide's tones, taken from the phone's own Material You colours like the rest of the app.
* Every size is taken from the screen's width, so the guide scales from small phones to tablets.
*/
internal class IntroColors(
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 tertiaryC: Color, val onTertiaryC: Color, val secondaryC: Color, val onSecondaryC: Color,
)
@Composable
internal fun introColors(): IntroColors {
val m = MaterialTheme.colorScheme
return IntroColors(
surface = m.surface, onSurface = m.onSurface, onVariant = m.onSurfaceVariant, outline = m.outline,
primary = m.primary, onPrimary = m.onPrimary, primaryC = m.primaryContainer, onPrimaryC = m.onPrimaryContainer,
tertiaryC = m.tertiaryContainer, onTertiaryC = m.onTertiaryContainer,
secondaryC = m.secondaryContainer, onSecondaryC = m.onSecondaryContainer,
)
}
private val Pop = CubicBezierEasing(0.3f, 1.6f, 0.5f, 1f)
private val Rise = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f)
/** 0 to 1 once each time [key] changes, after [delayMs]: drives the entrance of one element. */
@Composable
internal fun entrance(key: Any, delayMs: Int, durationMs: Int = 650): Float {
val a = remember(key) { Animatable(0f) }
LaunchedEffect(key) {
delay(delayMs.toLong())
a.animateTo(1f, tween(durationMs, easing = LinearEasing))
}
return a.value
}
/** Slides up and fades in, like the mockup's `up`. */
internal fun Modifier.up(p: Float, distance: Dp) = graphicsLayer {
val e = Rise.transform(p)
alpha = e
translationY = (1 - e) * distance.toPx()
}
/** Grows in with a bounce, like the mockup's `bounce`. */
internal fun Modifier.pop(p: Float) = graphicsLayer {
val e = Pop.transform(p)
alpha = p.coerceAtMost(1f)
scaleX = 0.5f + 0.5f * e
scaleY = 0.5f + 0.5f * e
}
/**
* 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
internal fun ThemePicker(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: Any) {
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
internal 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. */
@Composable
internal fun Cookie(color: Color, modifier: Modifier) {
Canvas(modifier) {
val unit = size.minDimension / 100f
val path = Path()
var deg = 0
while (deg <= 360) {
val a = deg * PI / 180
val r = (44 + 4.5 * cos(9 * a)) * unit
val x = center.x + (r * cos(a)).toFloat()
val y = center.y + (r * sin(a)).toFloat()
if (deg == 0) path.moveTo(x, y) else path.lineTo(x, y)
deg += 3
}
path.close()
drawPath(path, color)
}
}
/** Draws in the mockup's 48-unit icon box. */
private inline fun DrawScope.inIconBox(block: DrawScope.() -> Unit) {
val s = size.minDimension / 48f
scale(s, s, Offset.Zero) { block() }
}
@Composable
internal fun WifiIcon(color: Color) {
val t = rememberInfiniteTransition(label = "wifi")
val waves = (0..2).map { i ->
t.animateFloat(
0.35f, 1f,
infiniteRepeatable(tween(800, delayMillis = 0, easing = FastOutSlowInEasing), RepeatMode.Reverse,
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 200)),
label = "wave$i",
)
}
Canvas(Modifier.fillMaxSize()) {
inIconBox {
val stroke = Stroke(5f, cap = StrokeCap.Round)
// The mockup's three arcs, inner to outer: radius r through (24 ± half, y).
listOf(Triple(8f, 6f, 34f), Triple(18f, 13f, 26f), Triple(28f, 20f, 18f)).forEachIndexed { i, (r, half, y) ->
val cy = y + sqrt(r * r - half * half)
val start = Math.toDegrees(atan2((y - cy).toDouble(), (-half).toDouble())).toFloat()
val end = Math.toDegrees(atan2((y - cy).toDouble(), half.toDouble())).toFloat()
drawArc(color.copy(alpha = waves[i].value), start, (end - start + 360f) % 360f, false,
Offset(24f - r, cy - r), Size(2 * r, 2 * r), style = stroke)
}
drawCircle(color, 3.6f, Offset(24f, 41f))
}
}
}
@Composable
internal fun MicIcon(color: Color) {
val t = rememberInfiniteTransition(label = "mic")
val waves = (0..1).map { i ->
t.animateFloat(
0.25f, 1f,
infiniteRepeatable(tween(700, easing = FastOutSlowInEasing), RepeatMode.Reverse,
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 250)),
label = "mic$i",
)
}
Canvas(Modifier.fillMaxSize()) {
inIconBox {
drawRoundRect(color, Offset(18f, 5f), Size(12f, 22f), androidx.compose.ui.geometry.CornerRadius(6f))
val stroke = Stroke(4f, cap = StrokeCap.Round)
drawArc(color, 0f, 180f, false, Offset(12f, 14f), Size(24f, 22f), style = stroke)
drawLine(color, Offset(24f, 36f), Offset(24f, 42f), 4f, StrokeCap.Round)
// Sound waves either side, pulsing as if someone's talking.
listOf(1f to 0, -1f to 0, 1f to 1, -1f to 1).forEach { (side, i) ->
val r = if (i == 0) 15f else 21f
drawArc(color.copy(alpha = waves[i].value), if (side > 0) -35f else 145f, 70f, false,
Offset(24f - r, 16f - r), Size(2 * r, 2 * r), style = stroke)
}
}
}
}
@Composable
internal fun StarIcon(color: Color) {
val turn by rememberInfiniteTransition(label = "star").animateFloat(
-8f, 8f, infiniteRepeatable(tween(1600, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "star",
)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
val star = Path()
for (i in 0 until 10) {
val r = if (i % 2 == 0) 20f else 8.5f
val a = (-90 + i * 36) * PI / 180
val x = 24f + (r * cos(a)).toFloat()
val y = 25f + (r * sin(a)).toFloat()
if (i == 0) star.moveTo(x, y) else star.lineTo(x, y)
}
star.close()
rotate(turn, Offset(24f, 25f)) { drawPath(star, color) }
}
}
}
@Composable
internal fun HeartIcon(color: Color) {
val beat by rememberInfiniteTransition(label = "beat").animateFloat(
1f, 1f,
infiniteRepeatable(keyframes {
durationMillis = 1400
1f at 0; 1.16f at 210; 1f at 420; 1.1f at 630; 1f at 840
}),
label = "beat",
)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
scale(beat, beat, Offset(24f, 24f)) {
val heart = Path().apply {
moveTo(24f, 41f)
cubicTo(10f, 31f, 5f, 23f, 5f, 16f)
cubicTo(5f, 10f, 10f, 6f, 15f, 6f)
cubicTo(19f, 6f, 22f, 8f, 24f, 12f)
cubicTo(26f, 8f, 29f, 6f, 33f, 6f)
cubicTo(38f, 6f, 43f, 10f, 43f, 16f)
cubicTo(43f, 23f, 38f, 31f, 24f, 41f)
close()
}
drawPath(heart, color)
}
}
}
}
@Composable
internal fun Dots(c: IntroColors, count: Int, current: Int, cq: (Float) -> Dp, modifier: Modifier) {
Row(modifier, horizontalArrangement = Arrangement.spacedBy(cq(2f))) {
repeat(count) { i ->
val on = i == current
val width by animateDpAsState(if (on) cq(7f) else cq(2f), tween(300), label = "dot")
Box(
Modifier
.size(width, cq(2f))
.clip(RoundedCornerShape(50))
.background(if (on) c.primary else c.outline.copy(alpha = 0.45f)),
)
}
}
}
@@ -1,809 +0,0 @@
package app.seb3thehacker.gearslip.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.CertProvider
import app.seb3thehacker.gearslip.AppSettings
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.CompositingStrategy
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.material3.Icon
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Star
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.stats.UsageStats
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.atan2
import kotlin.math.sqrt
import kotlin.math.sin
import kotlin.random.Random
import kotlinx.coroutines.delay
/**
* The release intro: a few screens shown once after updating to a new release, before setup. New
* installs skip it. Its look follows the 0.3 mockup: Material shapes in the phone's own colours,
* with every size taken from the screen's width so it scales from small phones to tablets.
*/
object Intro {
private const val PREFS = "intro"
private const val KEY_SEEN = "seen_release"
private const val KEY_STEP = "step"
/**
* The release this intro is about. It's named here rather than taken from the version, so a
* patch release can carry an intro of its own: anyone who saw an earlier one sees this once.
*/
const val release: String = "0.3.12"
/**
* True once per release for people who used Gearslip before. A new install, or one with its
* data cleared, gets the setup guide instead: there's nothing "new" yet, so this release
* counts as seen and the next one shows. Install dates can't tell those apart, since
* clearing data keeps them, so this goes by what setup left behind; 0.2 saved the same.
*/
fun due(context: Context): Boolean {
if (context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_SEEN, null) == release) return false
val usedBefore = AppSettings.hasSeenPermissionsSetup(context) ||
AppSettings.hasSkippedCertSetup(context) || CertProvider.hasAnyCert(context)
if (!usedBefore) {
markSeen(context)
return false
}
return true
}
fun markSeen(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY_SEEN, release)
.remove(KEY_STEP)
.apply()
}
/** The page the intro was on, so an app restart part-way through resumes there. */
internal fun savedStep(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_STEP, null)
internal fun saveStep(context: Context, step: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_STEP, step).apply()
}
}
/** The intro's tones, taken from the phone's own Material You colours like the rest of the app. */
internal class IntroColors(
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 tertiaryC: Color, val onTertiaryC: Color, val secondaryC: Color, val onSecondaryC: Color,
)
@Composable
internal fun introColors(): IntroColors {
val m = MaterialTheme.colorScheme
return IntroColors(
surface = m.surface, onSurface = m.onSurface, onVariant = m.onSurfaceVariant, outline = m.outline,
primary = m.primary, onPrimary = m.onPrimary, primaryC = m.primaryContainer, onPrimaryC = m.onPrimaryContainer,
tertiaryC = m.tertiaryContainer, onTertiaryC = m.onTertiaryContainer,
secondaryC = m.secondaryContainer, onSecondaryC = m.onSecondaryContainer,
)
}
/** In the order shown: what's new, what to pick, housekeeping, then the people behind it. */
private enum class IntroStep { WELCOME, LOOK, CONNECTS, CALLS, SETTINGS }
private val Pop = CubicBezierEasing(0.3f, 1.6f, 0.5f, 1f)
private val Rise = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f)
@Composable
fun IntroScreen(onDone: () -> Unit) {
val context = LocalContext.current
val c = introColors()
val steps = remember { IntroStep.entries.toList() }
// Turning exploit protection back on restarts the app, so the intro picks up where it was.
var index by remember {
mutableIntStateOf(steps.indexOfFirst { it.name == Intro.savedStep(context) }.coerceAtLeast(0))
}
val step = steps[index]
LaunchedEffect(step) { Intro.saveStep(context, step.name) }
val finish = {
Intro.markSeen(context)
onDone()
}
BackHandler(enabled = index > 0) { index-- }
BoxWithConstraints(Modifier.fillMaxSize().background(c.surface)) {
val w = maxWidth
val h = maxHeight
// The mockup's cqw: one percent of the screen's width.
fun cq(n: Float): Dp = w * (n / 100f)
val density = LocalDensity.current
fun cqSp(n: Float): TextUnit = with(density) { cq(n).toSp() }
// The step on top, Back and Next under it, the same as the setup guide.
Column(Modifier.fillMaxSize()) {
Box(Modifier.weight(1f).fillMaxWidth()) {
when (step) {
IntroStep.WELCOME -> Welcome(c, ::cq, ::cqSp, h)
IntroStep.LOOK -> BadgeStep(c, ::cq, ::cqSp, c.primaryC, "A fresh look.",
"The car screen takes your phone's colors, and every card and button matches.", step) {
GlyphIcon(Icons.Filled.Star, c.onPrimaryC)
}
IntroStep.CONNECTS -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Connects by itself.",
"Say yes once after a wireless drive, and Gearslip connects whenever the car's Bluetooth does.", step) {
WifiIcon(c.onTertiaryC)
}
IntroStep.CALLS -> BadgeStep(c, ::cq, ::cqSp, c.primaryC, "Better calls.",
"Dial from the car, then mute or hang up from the nav bar.", step) {
GlyphIcon(Icons.Filled.Call, c.onPrimaryC)
}
IntroStep.SETTINGS -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Settings up front.",
"Change the car's settings from the phone's home screen, before you drive.", step) {
GlyphIcon(Icons.Filled.Settings, c.onSecondaryC)
}
}
}
Column(Modifier.fillMaxWidth().padding(horizontal = cq(8f))) {
Dots(c, steps.size, index, ::cq, Modifier.align(Alignment.CenterHorizontally))
Spacer(Modifier.height(cq(5f)))
PillRow(
c, ::cq, ::cqSp,
onBack = if (index > 0) ({ index-- }) else null,
label = if (index == steps.lastIndex) "Start" else "Next",
) { if (index < steps.lastIndex) index++ else finish() }
Spacer(Modifier.height(cq(6f)))
}
}
}
}
/** 0 to 1 once each time [key] changes, after [delayMs]: drives the entrance of one element. */
@Composable
internal fun entrance(key: Any, delayMs: Int, durationMs: Int = 650): Float {
val a = remember(key) { Animatable(0f) }
LaunchedEffect(key) {
delay(delayMs.toLong())
a.animateTo(1f, tween(durationMs, easing = LinearEasing))
}
return a.value
}
/** Slides up and fades in, like the mockup's `up`. */
internal fun Modifier.up(p: Float, distance: Dp) = graphicsLayer {
val e = Rise.transform(p)
alpha = e
translationY = (1 - e) * distance.toPx()
}
/** Grows in with a bounce, like the mockup's `bounce`. */
internal fun Modifier.pop(p: Float) = graphicsLayer {
val e = Pop.transform(p)
alpha = p.coerceAtMost(1f)
scaleX = 0.5f + 0.5f * e
scaleY = 0.5f + 0.5f * e
}
@Composable
private fun Welcome(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, height: Dp) {
val key = IntroStep.WELCOME
val spin by rememberInfiniteTransition(label = "spin").animateFloat(
0f, 360f, infiniteRepeatable(tween(18_000, easing = LinearEasing)), label = "spin",
)
Box(Modifier.fillMaxSize()) {
Cookie(c.primaryC, Modifier.size(cq(120f)).offset(x = cq(28f), y = -cq(22f)).align(Alignment.TopEnd).graphicsLayer { rotationZ = spin; alpha = 0.9f })
Ticker(c.secondaryC, c.onSecondaryC, "OPEN SOURCE ✦ NO ACCOUNT ✦ YOUR CAR, YOUR PHONE ✦ ", cq, cqSp, reverse = true, tilt = 3f,
key = key, modifier = Modifier.align(Alignment.TopCenter).padding(top = height * 0.11f))
Column(Modifier.align(Alignment.CenterStart).padding(horizontal = cq(8f))) {
Text("Welcome to", color = c.onVariant, style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.SemiBold),
modifier = Modifier.up(entrance(key, 100), cq(7f)))
Text(
Intro.release, color = c.primary,
style = TextStyle(fontSize = cqSp(44f), fontWeight = FontWeight.Black, letterSpacing = cqSp(-2.4f), lineHeight = cqSp(38f)),
modifier = Modifier.offset(x = -cq(1.5f)).pop(entrance(key, 250, 900)),
)
Text(
"alpha · a fresh look", color = c.onTertiaryC,
style = TextStyle(fontSize = cqSp(4f), fontWeight = FontWeight.Bold),
modifier = Modifier.padding(top = cq(4f)).up(entrance(key, 700), cq(7f))
.clip(RoundedCornerShape(50)).background(c.tertiaryC).padding(horizontal = cq(4.4f), vertical = cq(2.2f)),
)
}
Confetti(c, key, Modifier.fillMaxSize())
}
}
/** A tilted band of words scrolling past, slid in from the side when the step opens. */
@Composable
private fun Ticker(
bg: Color, fg: Color, words: String, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit,
reverse: Boolean, tilt: Float, key: Any, modifier: Modifier,
) {
val enter = entrance(key, 400, 800)
val scroll by rememberInfiniteTransition(label = "ticker").animateFloat(
0f, 1f, infiniteRepeatable(tween(if (reverse) 20_000 else 16_000, easing = LinearEasing)), label = "ticker",
)
Box(
modifier
.fillMaxWidth()
.graphicsLayer {
rotationZ = tilt
scaleX = 1.2f
val e = Rise.transform(enter)
alpha = e
translationX = (1 - e) * -cq(30f).toPx()
}
.background(bg)
.padding(vertical = cq(2.6f)),
) {
Text(
words.repeat(4),
color = fg, maxLines = 1, softWrap = false,
style = TextStyle(fontSize = cqSp(5f), fontWeight = FontWeight.ExtraBold),
modifier = Modifier
.wrapContentWidth(Alignment.Start, unbounded = true)
.graphicsLayer {
// The text holds the words four times; sliding by half loops cleanly.
val shift = size.width / 2f * (if (reverse) 1 - scroll else scroll)
translationX = -shift
},
)
}
}
/** Material shapes thrown once from behind the version number. */
@Composable
private fun Confetti(c: IntroColors, key: Any, modifier: Modifier) {
val p = entrance(key, 450, 1300)
val bits = remember {
val r = Random(7)
List(22) { i ->
val a = i / 22f * 2 * PI.toFloat() + r.nextFloat() * 0.4f
val d = 30f + r.nextFloat() * 45f
Bit(cos(a) * d, sin(a) * d * 1.2f, r.nextInt(-270, 270).toFloat(), i % 4, i % 3)
}
}
if (p <= 0f || p >= 1f) return
val tones = listOf(c.primary, c.primaryC, c.tertiaryC, c.secondaryC)
Canvas(modifier) {
val unit = size.width / 100f
val e = CubicBezierEasing(0.15f, 0.7f, 0.3f, 1f).transform(p)
val alpha = if (p < 0.7f) 1f else 1f - (p - 0.7f) / 0.3f
val origin = Offset(size.width * 0.3f, size.height * 0.42f)
bits.forEach { b ->
val at = origin + Offset(b.dx * unit * e, b.dy * unit * e)
val s = 3.6f * unit * (0.3f + 0.7f * e)
rotate(b.spin * e, at) {
val color = tones[b.tone].copy(alpha = alpha)
when (b.shape) {
0 -> drawCircle(color, s / 2, at)
1 -> drawRoundRect(color, at - Offset(s / 2, s / 2), Size(s, s), androidx.compose.ui.geometry.CornerRadius(unit))
else -> drawRoundRect(color, at - Offset(s / 2, s / 4), Size(s, s / 2), androidx.compose.ui.geometry.CornerRadius(s / 4))
}
}
}
}
}
private class Bit(val dx: Float, val dy: Float, val spin: Float, val tone: Int, val shape: Int)
/** One idea per screen: a spinning cookie with an icon, a headline, a line of text, maybe a button. */
@Composable
private fun BadgeStep(
c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, badge: Color,
title: String, body: String, key: Any,
action: Pair<String, () -> Unit>? = null, outlined: Boolean = false,
secondAction: Pair<String, () -> Unit>? = null,
extra: (@Composable () -> Unit)? = null,
icon: @Composable () -> Unit,
) {
val spin by rememberInfiniteTransition(label = "spin").animateFloat(
0f, 360f, infiniteRepeatable(tween(18_000, easing = LinearEasing)), label = "spin",
)
Column(
Modifier.fillMaxSize().padding(horizontal = cq(8f)),
verticalArrangement = Arrangement.Center,
) {
Box(Modifier.size(cq(34f)).pop(entrance(key, 50, 800)), contentAlignment = Alignment.Center) {
Cookie(badge, Modifier.fillMaxSize().graphicsLayer { rotationZ = spin })
Box(Modifier.size(cq(15f))) { icon() }
}
Spacer(Modifier.height(cq(8f)))
Text(
title, color = c.onSurface,
style = TextStyle(fontSize = cqSp(13.5f), fontWeight = FontWeight.Black, letterSpacing = cqSp(-0.6f), lineHeight = cqSp(12.5f)),
modifier = Modifier.up(entrance(key, 200, 700), cq(7f)),
)
Spacer(Modifier.height(cq(4f)))
Text(
body, color = c.onVariant,
style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)),
modifier = Modifier.up(entrance(key, 350, 700), cq(7f)),
)
extra?.invoke()
listOfNotNull(action, secondAction).forEachIndexed { i, (label, onClick) ->
Spacer(Modifier.height(if (i > 0) cq(3f) else if (outlined) cq(7f) else cq(9f)))
Text(
label,
color = if (outlined) c.primary else c.onPrimary,
style = TextStyle(fontSize = cqSp(if (outlined) 4.4f else 4.6f), fontWeight = FontWeight.Bold),
modifier = Modifier
.up(entrance(key, 500 + i * 150, 700), cq(7f))
.clip(RoundedCornerShape(50))
.then(
if (outlined) Modifier.border(BorderStroke(2.dp, c.outline), RoundedCornerShape(50))
else Modifier.background(c.primary),
)
.clickable(onClick = onClick)
.padding(horizontal = if (outlined) cq(7f) else cq(9f), vertical = if (outlined) cq(3.6f) else cq(4.2f)),
)
}
}
}
/**
* Voice: a few things to say, drifting past, and a button that fetches both speech models so the
* assistant works the first time someone tries it in the car.
*/
@Composable
private fun VoiceStep(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
val context = LocalContext.current
remember { SpeechModels.init(context) }
val needed = SpeechModels.RECOMMENDED
val states = needed.map { SpeechModels.state(it).collectAsState().value }
val ready = states.all { it == SpeechModels.State.Ready }
val label = when {
states.any { it is SpeechModels.State.Downloading } ->
"Downloading, ${needed.zip(states).sumOf { (m, st) -> m.downloadMb * percentOf(st) } / needed.sumOf { it.downloadMb }}%"
states.any { it is SpeechModels.State.Failed } -> "Download failed. Try again"
else -> "Download voice, ${needed.sumOf { it.downloadMb }} MB"
}
BadgeStep(
c, cq, cqSp, c.primaryC, "Just say it.",
"Tap the mic, or press the talk button on your wheel, and speak. Your phone turns speech " +
"into text by itself, and nothing you say leaves it. Say \"help\" in the car for the full list.",
key,
action = if (ready) null else label to { SpeechModels.downloadRecommended(context) },
outlined = true,
extra = { CommandDrift(c, cq, cqSp, key) },
) { MicIcon(c.onPrimaryC) }
}
private fun percentOf(state: SpeechModels.State) = when (state) {
SpeechModels.State.Ready -> 100
is SpeechModels.State.Downloading -> state.percent
else -> 0
}
private val SAMPLE_COMMANDS = listOf(
"Play Daft Punk", "What's playing?", "Next song",
"Call Mom", "Open Organic Maps", "Pause", "Help",
)
/** Example commands rising slowly through a window that fades at both edges. */
@Composable
private fun CommandDrift(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: Any) {
val scroll by rememberInfiniteTransition(label = "drift").animateFloat(
0f, 1f, infiniteRepeatable(tween(SAMPLE_COMMANDS.size * 2_200, easing = LinearEasing)), label = "drift",
)
Box(
Modifier
.padding(top = cq(6f))
.fillMaxWidth()
.height(cq(34f))
.up(entrance(key, 500, 700), cq(7f))
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(0f to Color.Transparent, 0.25f to Color.Black, 0.75f to Color.Black, 1f to Color.Transparent),
blendMode = BlendMode.DstIn,
)
}
.clipToBounds(),
) {
Column(
Modifier
.wrapContentHeight(Alignment.Top, unbounded = true)
// The list is there twice; rising by half loops cleanly.
.graphicsLayer { translationY = -size.height / 2f * scroll },
verticalArrangement = Arrangement.spacedBy(cq(2.4f)),
) {
repeat(2) {
SAMPLE_COMMANDS.forEach { say ->
Text(
"\u201c$say\u201d", color = c.onSecondaryC,
style = TextStyle(fontSize = cqSp(4.4f), fontWeight = FontWeight.SemiBold),
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(c.secondaryC)
.padding(horizontal = cq(4.4f), vertical = cq(2f)),
)
}
}
}
}
}
/** Each kind of app with its picks, the first recommended most. */
/**
* 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
internal fun ThemePicker(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: Any) {
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
internal 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. */
@Composable
internal fun Cookie(color: Color, modifier: Modifier) {
Canvas(modifier) {
val unit = size.minDimension / 100f
val path = Path()
var deg = 0
while (deg <= 360) {
val a = deg * PI / 180
val r = (44 + 4.5 * cos(9 * a)) * unit
val x = center.x + (r * cos(a)).toFloat()
val y = center.y + (r * sin(a)).toFloat()
if (deg == 0) path.moveTo(x, y) else path.lineTo(x, y)
deg += 3
}
path.close()
drawPath(path, color)
}
}
/** Draws in the mockup's 48-unit icon box. */
private inline fun DrawScope.inIconBox(block: DrawScope.() -> Unit) {
val s = size.minDimension / 48f
scale(s, s, Offset.Zero) { block() }
}
/** A Material glyph filling a badge, for steps without a drawing of their own. */
@Composable
private fun GlyphIcon(glyph: androidx.compose.ui.graphics.vector.ImageVector, color: Color) {
Icon(glyph, contentDescription = null, tint = color, modifier = Modifier.fillMaxSize())
}
@Composable
internal fun WifiIcon(color: Color) {
val t = rememberInfiniteTransition(label = "wifi")
val waves = (0..2).map { i ->
t.animateFloat(
0.35f, 1f,
infiniteRepeatable(tween(800, delayMillis = 0, easing = FastOutSlowInEasing), RepeatMode.Reverse,
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 200)),
label = "wave$i",
)
}
Canvas(Modifier.fillMaxSize()) {
inIconBox {
val stroke = Stroke(5f, cap = StrokeCap.Round)
// The mockup's three arcs, inner to outer: radius r through (24 ± half, y).
listOf(Triple(8f, 6f, 34f), Triple(18f, 13f, 26f), Triple(28f, 20f, 18f)).forEachIndexed { i, (r, half, y) ->
val cy = y + sqrt(r * r - half * half)
val start = Math.toDegrees(atan2((y - cy).toDouble(), (-half).toDouble())).toFloat()
val end = Math.toDegrees(atan2((y - cy).toDouble(), half.toDouble())).toFloat()
drawArc(color.copy(alpha = waves[i].value), start, (end - start + 360f) % 360f, false,
Offset(24f - r, cy - r), Size(2 * r, 2 * r), style = stroke)
}
drawCircle(color, 3.6f, Offset(24f, 41f))
}
}
}
@Composable
private fun DialIcon(color: Color) {
val angle by rememberInfiniteTransition(label = "dial").animateFloat(
-70f, -70f,
infiniteRepeatable(keyframes {
durationMillis = 2600
-70f at 0 using CubicBezierEasing(0.5f, 0f, 0.3f, 1f)
70f at 1040
70f at 1430 using CubicBezierEasing(0.5f, 0f, 0.3f, 1f)
-70f at 2470
}),
label = "dial",
)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
drawCircle(color, 19f, Offset(24f, 24f), style = Stroke(5f))
rotate(angle, Offset(24f, 24f)) {
drawRoundRect(color, Offset(21.5f, 7f), Size(5f, 13f), androidx.compose.ui.geometry.CornerRadius(2.5f))
}
}
}
}
@Composable
internal fun MicIcon(color: Color) {
val t = rememberInfiniteTransition(label = "mic")
val waves = (0..1).map { i ->
t.animateFloat(
0.25f, 1f,
infiniteRepeatable(tween(700, easing = FastOutSlowInEasing), RepeatMode.Reverse,
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 250)),
label = "mic$i",
)
}
Canvas(Modifier.fillMaxSize()) {
inIconBox {
drawRoundRect(color, Offset(18f, 5f), Size(12f, 22f), androidx.compose.ui.geometry.CornerRadius(6f))
val stroke = Stroke(4f, cap = StrokeCap.Round)
drawArc(color, 0f, 180f, false, Offset(12f, 14f), Size(24f, 22f), style = stroke)
drawLine(color, Offset(24f, 36f), Offset(24f, 42f), 4f, StrokeCap.Round)
// Sound waves either side, pulsing as if someone's talking.
listOf(1f to 0, -1f to 0, 1f to 1, -1f to 1).forEach { (side, i) ->
val r = if (i == 0) 15f else 21f
drawArc(color.copy(alpha = waves[i].value), if (side > 0) -35f else 145f, 70f, false,
Offset(24f - r, 16f - r), Size(2 * r, 2 * r), style = stroke)
}
}
}
}
@Composable
internal fun StarIcon(color: Color) {
val turn by rememberInfiniteTransition(label = "star").animateFloat(
-8f, 8f, infiniteRepeatable(tween(1600, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "star",
)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
val star = Path()
for (i in 0 until 10) {
val r = if (i % 2 == 0) 20f else 8.5f
val a = (-90 + i * 36) * PI / 180
val x = 24f + (r * cos(a)).toFloat()
val y = 25f + (r * sin(a)).toFloat()
if (i == 0) star.moveTo(x, y) else star.lineTo(x, y)
}
star.close()
rotate(turn, Offset(24f, 25f)) { drawPath(star, color) }
}
}
}
@Composable
internal fun ShieldIcon(color: Color, tick: Color) {
val p = entrance("shield", 800, 500)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
val shield = Path().apply {
moveTo(24f, 4f); lineTo(40f, 10f); lineTo(40f, 22f)
cubicTo(40f, 32f, 33f, 40f, 24f, 44f)
cubicTo(15f, 40f, 8f, 32f, 8f, 22f)
lineTo(8f, 10f); close()
}
drawPath(shield, color)
drawPartialStroke(listOf(Offset(16f, 24f), Offset(22f, 30f), Offset(33f, 18f)), p, tick, 4.5f)
}
}
}
/** A speech bubble with three dots that pulse, like someone typing. */
@Composable
private fun ChatIcon(color: Color, dots: Color) {
val t = rememberInfiniteTransition(label = "typing")
val pulse = (0..2).map { i ->
t.animateFloat(
0.35f, 1f,
infiniteRepeatable(tween(500, easing = FastOutSlowInEasing), RepeatMode.Reverse,
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 170)),
label = "dot$i",
)
}
Canvas(Modifier.fillMaxSize()) {
inIconBox {
drawRoundRect(color, Offset(4f, 7f), Size(40f, 28f), CornerRadius(10f, 10f))
drawPath(Path().apply { moveTo(12f, 33f); lineTo(10f, 43f); lineTo(22f, 33f); close() }, color)
pulse.forEachIndexed { i, a -> drawCircle(dots.copy(alpha = a.value), 3.2f, Offset(15f + i * 9f, 21f)) }
}
}
}
@Composable
internal fun HeartIcon(color: Color) {
val beat by rememberInfiniteTransition(label = "beat").animateFloat(
1f, 1f,
infiniteRepeatable(keyframes {
durationMillis = 1400
1f at 0; 1.16f at 210; 1f at 420; 1.1f at 630; 1f at 840
}),
label = "beat",
)
Canvas(Modifier.fillMaxSize()) {
inIconBox {
scale(beat, beat, Offset(24f, 24f)) {
val heart = Path().apply {
moveTo(24f, 41f)
cubicTo(10f, 31f, 5f, 23f, 5f, 16f)
cubicTo(5f, 10f, 10f, 6f, 15f, 6f)
cubicTo(19f, 6f, 22f, 8f, 24f, 12f)
cubicTo(26f, 8f, 29f, 6f, 33f, 6f)
cubicTo(38f, 6f, 43f, 10f, 43f, 16f)
cubicTo(43f, 23f, 38f, 31f, 24f, 41f)
close()
}
drawPath(heart, color)
}
}
}
}
/** A polyline drawn up to [progress] of its length, so a tick can sweep in. */
private fun DrawScope.drawPartialStroke(points: List<Offset>, progress: Float, color: Color, width: Float) {
if (progress <= 0f) return
val lengths = points.zipWithNext { a, b -> (b - a).getDistance() }
var left = lengths.sum() * FastOutSlowInEasing.transform(progress.coerceAtMost(1f))
val path = Path().apply { moveTo(points[0].x, points[0].y) }
for (i in lengths.indices) {
if (left <= 0f) break
val f = (left / lengths[i]).coerceAtMost(1f)
val to = points[i] + (points[i + 1] - points[i]) * f
path.lineTo(to.x, to.y)
left -= lengths[i]
}
drawPath(path, color, style = Stroke(width, cap = StrokeCap.Round, join = StrokeJoin.Round))
}
@Composable
internal fun Dots(c: IntroColors, count: Int, current: Int, cq: (Float) -> Dp, modifier: Modifier) {
Row(modifier, horizontalArrangement = Arrangement.spacedBy(cq(2f))) {
repeat(count) { i ->
val on = i == current
val width by animateDpAsState(if (on) cq(7f) else cq(2f), tween(300), label = "dot")
Box(
Modifier
.size(width, cq(2f))
.clip(RoundedCornerShape(50))
.background(if (on) c.primary else c.outline.copy(alpha = 0.45f)),
)
}
}
}