From 0d0bfd3d665c1218999dade141cb00795f98d4e1 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Tue, 13 Oct 2026 01:14:06 -0700 Subject: [PATCH] setup: the 0.3 look, with a badge, a big headline and shorter words on every step --- .../gearslip/ui/SetupGuideScreen.kt | 354 ++++++++++-------- .../gearslip/ui/UsageStatsChoices.kt | 29 ++ 2 files changed, 234 insertions(+), 149 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt index 5de6a36..10ce412 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt @@ -1,6 +1,38 @@ package app.seb3thehacker.gearslip.ui import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +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.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -27,15 +59,7 @@ 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.material3.Button -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -66,14 +90,23 @@ private class GuidePage( val title: String, /** Plain text, or an [AnnotatedString] when part of it needs to stand out. */ val explanation: CharSequence, + /** Drawn inside the page's badge, in the badge's content colour. */ + val icon: @Composable (Color) -> Unit, + /** Marked "Optional" above the title: everything works without it. */ + val optional: Boolean = false, val permissions: Array? = null, val command: String? = null, val actionLabel: String? = null, val onAction: (() -> Unit)? = null, - /** Drawn under [explanation], inside the card. */ + /** Drawn under [explanation]. */ val content: (@Composable () -> Unit)? = null, ) +/** A plain Material icon, sized to fill the badge's icon box. */ +private fun glyph(icon: ImageVector): @Composable (Color) -> Unit = { tint -> + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.fillMaxSize()) +} + /** True once every permission in [permissions] is already granted - nothing left for this page to do. */ private fun granted(context: Context, permissions: Array): Boolean = permissions.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED } @@ -105,9 +138,11 @@ private fun hasNotificationAccess(context: Context): Boolean { * who set up before the notes existed see once. Leaving it without turning them on is a no. */ private fun usageNotesPage(context: Context) = GuidePage( - title = USAGE_STATS_TITLE, - explanation = USAGE_STATS_INTRO, + title = "Help other drivers.", + explanation = USAGE_STATS_SHORT, + icon = { HeartIcon(it) }, content = { + UsageStatsReadMore() UsageStatsChoices() DisposableEffect(Unit) { onDispose { if (!UsageStats.asked(context)) UsageStats.set(context, false) } @@ -119,11 +154,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit, onDone: () -> Unit, onl context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED } ?: false - Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> - Column( - Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(Modifier.height(24.dp)) - // A single page has no progress to show. - if (pages.size > 1) { - LinearProgressIndicator( - progress = { (index + 1f) / pages.size }, - modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge), - ) - Spacer(Modifier.height(12.dp)) - Text( - "${index + 1} of ${pages.size}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - // The page scrolls between the progress bar and the buttons, so a tall page (the usage - // note's choices, say) never pushes Next off the screen. Short pages stay centred. + val c = introColors() + // The badges take turns through the phone's three accent colours, as the release intro does. + val (badge, onBadge) = listOf(c.primaryC to c.onPrimaryC, c.secondaryC to c.onSecondaryC, c.tertiaryC to c.onTertiaryC)[index % 3] + val spin by rememberInfiniteTransition(label = "spin").animateFloat( + 0f, 360f, infiniteRepeatable(tween(18_000, easing = LinearEasing)), label = "spin", + ) + + BoxWithConstraints(Modifier.fillMaxSize().background(c.surface).systemBarsPadding()) { + val w = maxWidth + // One percent of the screen's width, as in the intro, so it scales the same way. + fun cq(n: Float): Dp = w * (n / 100f) + val density = LocalDensity.current + fun cqSp(n: Float): TextUnit = with(density) { cq(n).toSp() } + + Column(Modifier.fillMaxSize().padding(horizontal = cq(8f))) { + // The page scrolls above the buttons, so a tall one (the usage notes' choices) never + // pushes them off the screen. Short pages sit in the middle. BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { key(index) { Column( @@ -357,88 +387,114 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl .fillMaxWidth() .verticalScroll(rememberScrollState()) .heightIn(min = maxHeight) - .padding(vertical = 24.dp), + .padding(vertical = cq(6f)), verticalArrangement = Arrangement.Center, ) { - Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) { - Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Box(Modifier.size(cq(28f)).pop(entrance(index, 50, 800)), contentAlignment = Alignment.Center) { + Cookie(badge, Modifier.fillMaxSize().graphicsLayer { rotationZ = spin }) + Box(Modifier.size(cq(12f))) { page.icon(onBadge) } + } + Spacer(Modifier.height(cq(7f))) + if (page.optional) { + Text( + "OPTIONAL", color = c.primary, + style = TextStyle(fontSize = cqSp(3.4f), fontWeight = FontWeight.Bold, letterSpacing = cqSp(0.4f)), + modifier = Modifier.up(entrance(index, 150, 700), cq(7f)), + ) + Spacer(Modifier.height(cq(1.5f))) + } + Text( + page.title, color = c.onSurface, + // Long titles step down a size so they stay on two lines. + style = TextStyle( + fontSize = cqSp(if (page.title.length > 16) 8f else 10f), fontWeight = FontWeight.Black, + letterSpacing = cqSp(-0.4f), lineHeight = cqSp(if (page.title.length > 16) 8.6f else 10.5f), + ), + modifier = Modifier.up(entrance(index, 200, 700), cq(7f)), + ) + Spacer(Modifier.height(cq(4f))) + Column( + Modifier.up(entrance(index, 350, 700), cq(7f)), + verticalArrangement = Arrangement.spacedBy(cq(3f)), + ) { + Text( + page.explanation as? AnnotatedString ?: AnnotatedString(page.explanation.toString()), + color = c.onVariant, + style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)), + ) + page.command?.let { CopyableCommand(it) } + page.content?.invoke() + if (page.permissions != null && alreadyGranted) { Text( - page.title, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, + "Already allowed.", color = c.primary, + style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.SemiBold), ) - Text( - page.explanation as? AnnotatedString ?: AnnotatedString(page.explanation.toString()), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - page.command?.let { CopyableCommand(it) } - page.content?.invoke() } } } } } + // A single page has nothing to count. + if (pages.size > 1) { + Dots(c, pages.size, index, ::cq, Modifier.align(Alignment.CenterHorizontally)) + Spacer(Modifier.height(cq(5f))) + } + val back = if (index > 0) ({ index -= 1 }) else null + val next = { index += 1 } when { - page.permissions != null && alreadyGranted -> { - Text( - "Already allowed.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(16.dp)) - PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) { - Button(onClick = { index += 1 }, modifier = it) { Text("Next") } - } - } - page.permissions != null -> { - PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) { - Button(onClick = { launcher.launch(page.permissions) }, modifier = it) { Text("Allow") } - } - Spacer(Modifier.height(8.dp)) - TextButton(onClick = { index += 1 }) { Text("Skip for now") } + page.permissions != null && !alreadyGranted -> { + PillRow(c, ::cq, ::cqSp, back, "Allow") { launcher.launch(page.permissions) } + Pill(c, ::cqSp, "Skip for now", PillStyle.TEXT, Modifier.align(Alignment.CenterHorizontally), next) } page.onAction != null -> { - FilledTonalButton( - onClick = page.onAction, - modifier = Modifier.fillMaxWidth().height(52.dp), - ) { Text(page.actionLabel ?: "Open") } - Spacer(Modifier.height(8.dp)) - PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) { - Button(onClick = { index += 1 }, modifier = it) { Text("Next") } - } - } - else -> { - PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) { - Button(onClick = { index += 1 }, modifier = it) { - Text(if (index == pages.lastIndex) "Done" else "Next") - } - } + Pill(c, ::cqSp, page.actionLabel ?: "Open", PillStyle.OUTLINED, Modifier.fillMaxWidth().padding(bottom = cq(3f)), page.onAction) + PillRow(c, ::cq, ::cqSp, back, "Next", next) } + else -> PillRow(c, ::cq, ::cqSp, back, if (index == pages.lastIndex) "Done" else "Next", next) } - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(cq(6f))) } } } -/** - * The bottom action row: a lower-emphasis Back on the left once there is a previous page, and the - * page's own primary button - passed [content] so the caller keeps the button's own label and - * click behavior - filling the rest of the width. - */ +private 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. */ @Composable -private fun PrimaryRow(showBack: Boolean, onBack: () -> Unit, content: @Composable (Modifier) -> Unit) { - if (showBack) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedButton( - onClick = onBack, - modifier = Modifier.weight(1f).height(52.dp), - ) { Text("Back") } - content(Modifier.weight(1f).height(52.dp)) - } - } else { - content(Modifier.fillMaxWidth().height(52.dp)) +private fun Pill(c: IntroColors, cqSp: (Float) -> TextUnit, label: String, style: PillStyle, modifier: Modifier = Modifier, onClick: () -> Unit) { + val shape = RoundedCornerShape(50) + Box( + modifier + .clip(shape) + .then( + when (style) { + PillStyle.FILLED -> Modifier.background(c.primary) + PillStyle.OUTLINED -> Modifier.border(BorderStroke(2.dp, c.outline), shape) + PillStyle.TEXT -> Modifier + }, + ) + .clickable(onClick = onClick) + .heightIn(min = 52.dp) + .padding(horizontal = 20.dp, vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + label, + color = if (style == PillStyle.FILLED) c.onPrimary else c.primary, + style = TextStyle(fontSize = cqSp(4.4f), fontWeight = FontWeight.Bold), + ) + } +} + +/** Back on the left once there's a page to go back to, and the page's main action filling the rest. */ +@Composable +private fun PillRow( + c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, + onBack: (() -> Unit)?, label: String, onClick: () -> Unit, +) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(cq(3f))) { + if (onBack != null) Pill(c, cqSp, "Back", PillStyle.OUTLINED, Modifier.weight(1f), onBack) + Pill(c, cqSp, label, PillStyle.FILLED, Modifier.weight(if (onBack != null) 1.4f else 1f), onClick) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt index a6a5469..66e6180 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt @@ -37,6 +37,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.TextButton import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString @@ -70,6 +74,31 @@ internal val USAGE_STATS_INTRO: AnnotatedString = buildAnnotatedString { append(INTRO_END) } +/** + * The setup guide's version of [USAGE_STATS_INTRO]: what the notes are and why, in three + * sentences. The full text is one tap away in [UsageStatsReadMore]. + */ +internal val USAGE_STATS_SHORT: AnnotatedString = buildAnnotatedString { + append("Gearslip can send a short note after each drive: its version and the car details you pick below. ") + append("The notes show which cars work and where the others fail. ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("No name, account, or location goes with them.") } +} + +/** "Read more", opening the whole pitch and privacy promise in a dialog. */ +@Composable +internal fun UsageStatsReadMore() { + var open by remember { mutableStateOf(false) } + TextButton(onClick = { open = true }, contentPadding = PaddingValues(0.dp)) { Text("Read more") } + if (open) { + AlertDialog( + onDismissRequest = { open = false }, + title = { Text("About usage notes") }, + text = { Text(USAGE_STATS_INTRO, modifier = Modifier.verticalScroll(rememberScrollState())) }, + confirmButton = { TextButton(onClick = { open = false }) { Text("Close") } }, + ) + } +} + /** The usage notes card for the setup guide: the same rows Settings shows. */ @Composable internal fun UsageStatsChoices(modifier: Modifier = Modifier) {