setup: end on the app list, the button picker, then usage notes; intro keeps what's new

The intro drops directions, app picks and the button picker. The setup guide's
long pages fade out with a scroll hint, and the app list's names read on dark.
This commit is contained in:
Seb3thehacker committed 2026-10-24 16:12:51 -07:00
1 parent 8d673d855b
commit 0a5acc012e
3 files changed
+54 -81

No files matched your search

@@ -171,7 +171,7 @@ internal fun introColors(): IntroColors {
}
/** In the order shown: what's new, what to pick, housekeeping, then the people behind it. */
private enum class IntroStep { WELCOME, LOOK, DIRECTIONS, CONNECTS, CALLS, SETTINGS, APPS, THEME }
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)
@@ -210,10 +210,6 @@ fun IntroScreen(onDone: () -> Unit) {
"The car screen takes your phone's colors, and every card and button matches.", step) {
GlyphIcon(Icons.Filled.Star, c.onPrimaryC)
}
IntroStep.DIRECTIONS -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Directions at a glance.",
"A big turn card on the left shows the arrow, the distance, and your lane. The map keeps the right side.", step) {
GlyphIcon(Icons.Filled.KeyboardArrowUp, c.onSecondaryC)
}
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)
@@ -226,12 +222,6 @@ fun IntroScreen(onDone: () -> Unit) {
"Change the car's settings from the phone's home screen, before you drive.", step) {
GlyphIcon(Icons.Filled.Settings, c.onSecondaryC)
}
IntroStep.APPS -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Good company.",
"Free, open-source apps that work well in the car. The first in each row works best. Tap one to see it on GitHub.", step,
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) }
}
}
Column(Modifier.fillMaxWidth().padding(horizontal = cq(8f))) {
@@ -518,69 +508,12 @@ private fun CommandDrift(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> Tex
}
/** Each kind of app with its picks, the first recommended most. */
private val APP_PICKS = listOf(
"Maps" to listOf(
"Organic Maps" to "https://github.com/organicmaps/organicmaps",
"Vela" to "https://github.com/PimpinPumpkin/Vela",
),
"Music" to listOf(
"ViVi Music" to "https://github.com/vivizzz007/vivi-music",
"Metrolist" to "https://github.com/MetrolistGroup/Metrolist",
),
"Podcasts" to listOf(
"AntennaPod" to "https://github.com/AntennaPod/AntennaPod",
),
"Other cool apps" to listOf(
"RF Sentinel" to "https://github.com/CIS-C0/RFSentinel",
),
)
/** One row per kind, with a chip per app opening its GitHub page; the rows arrive one after another. */
@Composable
private fun AppPicks(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
val context = LocalContext.current
Column(Modifier.padding(top = cq(6f)), verticalArrangement = Arrangement.spacedBy(cq(2.6f))) {
APP_PICKS.forEachIndexed { i, (kind, apps) ->
Column(
Modifier
.fillMaxWidth()
.up(entrance(key, 500 + i * 120, 700), cq(7f))
.clip(RoundedCornerShape(cq(5f)))
.background(c.secondaryC)
.padding(horizontal = cq(4f), vertical = cq(3f)),
) {
Text(kind.uppercase(), color = c.onSecondaryC.copy(alpha = 0.7f),
style = TextStyle(fontSize = cqSp(3.2f), fontWeight = FontWeight.Bold, letterSpacing = cqSp(0.3f)),
modifier = Modifier.padding(start = cq(1f), bottom = cq(1.6f)))
Row(horizontalArrangement = Arrangement.spacedBy(cq(2f))) {
apps.forEach { (name, url) ->
Text(
"$name ↗", color = c.onSecondaryC,
style = TextStyle(fontSize = cqSp(4.4f), fontWeight = FontWeight.ExtraBold),
maxLines = 1,
modifier = Modifier
.clip(RoundedCornerShape(50))
.border(BorderStroke(1.5.dp, c.onSecondaryC.copy(alpha = 0.35f)), RoundedCornerShape(50))
.clickable {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
.padding(horizontal = cq(3.6f), vertical = cq(2f)),
)
}
}
}
}
}
}
/**
* 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) {
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.
@@ -628,7 +561,7 @@ private fun ThemePicker(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> Text
/** Three keys of a keypad, pressing one after another. */
@Composable
private fun ButtonsIcon(color: Color) {
internal fun ButtonsIcon(color: Color) {
val t by rememberInfiniteTransition(label = "keys").animateFloat(
0f, 3f, infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "keys",
)
@@ -138,11 +138,13 @@ private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) {
if (icon != null) {
Image(icon.toBitmap(96, 96).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize())
} else {
Text(app.name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(app.name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
}
}
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
Text(app.name, style = MaterialTheme.typography.titleMedium)
// Colours set outright: the setup guide draws this on its own background, with no
// Surface to hand down a content colour, and unset text came out dark on dark.
Text(app.name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
Text(app.about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (icon != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
@@ -93,6 +93,9 @@ import app.seb3thehacker.gearslip.host.AndroidAuto
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
import app.seb3thehacker.gearslip.stats.UsageStats
import androidx.compose.runtime.DisposableEffect
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.foundation.layout.offset
/**
* One page of the guide. [permissions] drives a system permission dialog; [command] is an adb
@@ -100,6 +103,9 @@ import androidx.compose.runtime.DisposableEffect
* settings screen) without by itself finishing the page. Everything is skippable: none of this
* can be verified as done, only offered.
*/
/** The guide's colours and its one-percent-of-the-width scale, for content that sizes itself the same way. */
internal class GuideScale(val c: IntroColors, val cq: (Float) -> Dp, val cqSp: (Float) -> TextUnit)
private class GuidePage(
val title: String,
/** Plain text, or an [AnnotatedString] when part of it needs to stand out. */
@@ -112,8 +118,8 @@ private class GuidePage(
val command: String? = null,
val actionLabel: String? = null,
val onAction: (() -> Unit)? = null,
/** Drawn under [explanation]. */
val content: (@Composable () -> Unit)? = null,
/** Drawn under [explanation], sized off the same screen-width scale as the page. */
val content: (@Composable (GuideScale) -> Unit)? = null,
/** Only shown when the driver picked USB audio on the car audio page. */
val usbOnly: Boolean = false,
)
@@ -365,11 +371,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
)
}
// --- Near the end, every time: usage notes. On a replay, the switch shows what was
// chosen before.
add(usageNotesPage(context))
// --- Last of all, every time: apps worth installing, now that everything is set up.
// --- At the end, every time: apps worth installing, how the car's buttons look, and last
// the usage notes. On a replay, the switch shows what was chosen before.
add(
GuidePage(
title = "Good company.",
@@ -378,6 +381,15 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
content = { RecommendedAppsList() },
),
)
add(
GuidePage(
title = "Your buttons.",
explanation = "Pick how the car's buttons look. Change it any time in the car's settings.",
icon = { ButtonsIcon(it) },
content = { ThemePicker(it.c, it.cq, it.cqSp, key = "guide-buttons") },
),
)
add(usageNotesPage(context))
}
/**
@@ -507,10 +519,11 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
// pushes them off the screen. Short pages sit in the middle.
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
key(index) {
val scroll = rememberScrollState()
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.verticalScroll(scroll)
.heightIn(min = maxHeight)
.padding(vertical = cq(6f)),
verticalArrangement = Arrangement.Center,
@@ -548,7 +561,7 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)),
)
page.command?.let { CopyableCommand(it) }
page.content?.invoke()
page.content?.invoke(GuideScale(c, ::cq, ::cqSp))
if (page.permissions != null && alreadyGranted) {
Text(
"Already allowed.", color = c.primary,
@@ -557,6 +570,9 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
}
}
}
// A long page (the app list, the usage notes) says so: a fade over the
// last lines and a nudge to keep going, until the bottom is in view.
if (scroll.canScrollForward) ScrollHint(c, ::cq, ::cqSp, Modifier.align(Alignment.BottomCenter))
}
}
@@ -583,6 +599,28 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
}
}
/** A fade into the page's background with "Scroll for more" over it, at the bottom of a page that goes on. */
@Composable
private fun ScrollHint(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, modifier: Modifier = Modifier) {
val bob by rememberInfiniteTransition(label = "hint").animateFloat(
0f, 1f, infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "bob",
)
Box(
modifier
.fillMaxWidth()
.height(cq(22f))
.background(androidx.compose.ui.graphics.Brush.verticalGradient(listOf(c.surface.copy(alpha = 0f), c.surface))),
contentAlignment = Alignment.BottomCenter,
) {
Text(
"Scroll for more ↓",
color = c.primary,
style = TextStyle(fontSize = cqSp(4f), fontWeight = FontWeight.Bold),
modifier = Modifier.padding(bottom = cq(2f)).offset(y = cq(1.2f) * bob),
)
}
}
internal enum class PillStyle { FILLED, OUTLINED, TEXT }
/** The intro's rounded buttons: filled for the step's main action, outlined beside it, bare text to skip. */