setup: the 0.3 look, with a badge, a big headline and shorter words on every step

This commit is contained in:
Seb3thehacker committed 2026-10-13 01:28:54 -07:00
1 parent b3f2088a1d
commit 0d0bfd3d66
2 files changed
+234 -149

No files matched your search

@@ -1,6 +1,38 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.layout.BoxWithConstraints 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.layout.heightIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.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.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.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@@ -66,14 +90,23 @@ private class GuidePage(
val title: String, val title: String,
/** Plain text, or an [AnnotatedString] when part of it needs to stand out. */ /** Plain text, or an [AnnotatedString] when part of it needs to stand out. */
val explanation: CharSequence, 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<String>? = null, val permissions: Array<String>? = null,
val command: String? = null, val command: String? = null,
val actionLabel: String? = null, val actionLabel: String? = null,
val onAction: (() -> Unit)? = null, val onAction: (() -> Unit)? = null,
/** Drawn under [explanation], inside the card. */ /** Drawn under [explanation]. */
val content: (@Composable () -> Unit)? = null, 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. */ /** True once every permission in [permissions] is already granted - nothing left for this page to do. */
private fun granted(context: Context, permissions: Array<String>): Boolean = private fun granted(context: Context, permissions: Array<String>): Boolean =
permissions.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED } 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. * who set up before the notes existed see once. Leaving it without turning them on is a no.
*/ */
private fun usageNotesPage(context: Context) = GuidePage( private fun usageNotesPage(context: Context) = GuidePage(
title = USAGE_STATS_TITLE, title = "Help other drivers.",
explanation = USAGE_STATS_INTRO, explanation = USAGE_STATS_SHORT,
icon = { HeartIcon(it) },
content = { content = {
UsageStatsReadMore()
UsageStatsChoices() UsageStatsChoices()
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { if (!UsageStats.asked(context)) UsageStats.set(context, false) } onDispose { if (!UsageStats.asked(context)) UsageStats.set(context, false) }
@@ -119,11 +154,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
// --- Safety: always first, and never skipped by an already-granted check --------- // --- Safety: always first, and never skipped by an already-granted check ---------
add( add(
GuidePage( GuidePage(
title = "Drive safely", title = "Drive safely.",
explanation = "This guide sets up the screen you will see mounted in your car. " + explanation = "Gearslip is unofficial. No carmaker made or checked it, and it can fail " +
"Gearslip is unofficial software: it is not made or reviewed by any carmaker, " + "without warning. Set it up before you go, then keep your eyes on the road.",
"and it can fail without warning. Never interact with it while driving - set it " + icon = glyph(Icons.Filled.Warning),
"up, mount the phone, then keep your attention on the road.",
), ),
) )
@@ -132,10 +166,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) { if (!granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) {
add( add(
GuidePage( GuidePage(
title = "Microphone", title = "Microphone.",
explanation = "Gearslip needs the microphone for two jobs: passing your music " + explanation = "Gearslip uses it to send your music to the car and to hear you when you " +
"app's sound to the car, and hearing you when you reply to a message or ask " + "ask for something or reply to a message. What you say stays on the phone.",
"the assistant. It listens only then, and your speech stays on the phone.", icon = { MicIcon(it) },
permissions = arrayOf(Manifest.permission.RECORD_AUDIO), permissions = arrayOf(Manifest.permission.RECORD_AUDIO),
), ),
) )
@@ -143,9 +177,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!granted(context, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))) { if (!granted(context, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))) {
add( add(
GuidePage( GuidePage(
title = "Location", title = "Location.",
explanation = "The car's map and nearby places need to know where you are. " + explanation = "The car's map and nearby searches need it. Without it, the dashboard " +
"Without it, the dashboard has no map card.", "shows no map.",
icon = glyph(Icons.Filled.LocationOn),
permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
), ),
) )
@@ -153,9 +188,9 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!granted(context, arrayOf(Manifest.permission.READ_CALENDAR))) { if (!granted(context, arrayOf(Manifest.permission.READ_CALENDAR))) {
add( add(
GuidePage( GuidePage(
title = "Calendar", title = "Calendar.",
explanation = "Gearslip reads your next event to show an agenda card on the car " + explanation = "The car screen shows your next event. Gearslip only reads your calendar.",
"screen. It only reads; it never edits or deletes anything.", icon = glyph(Icons.Filled.DateRange),
permissions = arrayOf(Manifest.permission.READ_CALENDAR), permissions = arrayOf(Manifest.permission.READ_CALENDAR),
), ),
) )
@@ -170,11 +205,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!granted(context, callPermissions)) { if (!granted(context, callPermissions)) {
add( add(
GuidePage( GuidePage(
title = "Phone and contacts", title = "Calls and contacts.",
explanation = "When a call comes in, Gearslip matches the number against your " + explanation = "The car shows who's calling by name, and its Phone app can dial a " +
"contacts, so the car screen can show a name instead of just digits. The " + "number or call one of your contacts. Gearslip only reads them.",
"same permissions let the car screen's Phone app dial a number or call " + icon = glyph(Icons.Filled.Phone),
"someone from your contacts.",
permissions = callPermissions, permissions = callPermissions,
), ),
) )
@@ -183,10 +217,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!granted(context, arrayOf(Manifest.permission.POST_NOTIFICATIONS))) { if (!granted(context, arrayOf(Manifest.permission.POST_NOTIFICATIONS))) {
add( add(
GuidePage( GuidePage(
title = "Notifications", title = "Notifications.",
explanation = "Gearslip shows a notification while it's connected to the car or " + explanation = "While Gearslip is connected, it shows a notification with a button " +
"sending it audio, with a button to stop. Without this permission, Android " + "to stop. Without this, Android hides it.",
"hides it.", icon = glyph(Icons.Filled.Notifications),
permissions = arrayOf(Manifest.permission.POST_NOTIFICATIONS), permissions = arrayOf(Manifest.permission.POST_NOTIFICATIONS),
), ),
) )
@@ -194,10 +228,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!hasNotificationAccess(context)) { if (!hasNotificationAccess(context)) {
add( add(
GuidePage( GuidePage(
title = "Notification access", title = "Messages.",
explanation = "The car screen shows your phone's notifications and lets you " + explanation = "The car shows your messages, reads them aloud and lets you reply. " +
"reply from there. Android only allows that once you grant Gearslip access, " + "Turn Gearslip on in the list this opens.",
"from the list this opens - it needs this to work at all.", icon = glyph(Icons.Filled.Email),
actionLabel = "Open notification access", actionLabel = "Open notification access",
onAction = { onAction = {
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
@@ -212,10 +246,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (androidAutoPackage != null) { if (androidAutoPackage != null) {
add( add(
GuidePage( GuidePage(
title = "Remove Android Auto", title = "Turn off Android Auto.",
explanation = "Android Auto is still installed. It claims the same USB " + explanation = "Android Auto is still installed, and it fights Gearslip for the car's " +
"connection Gearslip needs, so the two fight over it. Disable or uninstall " + "USB connection. Disable or uninstall it on the screen this opens.",
"it from the app info screen this opens.", icon = glyph(Icons.Filled.Close),
actionLabel = "Open Android Auto's app info", actionLabel = "Open Android Auto's app info",
onAction = { onAction = {
context.startActivity( context.startActivity(
@@ -229,11 +263,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
// --- Default handler --------------------------------------------------------------- // --- Default handler ---------------------------------------------------------------
add( add(
GuidePage( GuidePage(
title = "Make Gearslip the default", title = "Choose Gearslip.",
explanation = "Android has no separate default-app setting for a USB accessory like " + explanation = "If your phone asks which app to open when you plug into the car, pick " +
"the car. It just launches whichever single app matches - which is Gearslip, " + "Gearslip and tap Always.",
"now that Android Auto is out of the way. If a chooser still appears when you " + icon = glyph(Icons.Filled.Check),
"plug in, pick Gearslip and tap Always so Android stops asking.",
), ),
) )
@@ -241,10 +274,11 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!hasProjectMediaOp(context)) { if (!hasProjectMediaOp(context)) {
add( add(
GuidePage( GuidePage(
title = "Optional: trust audio every drive", title = "No audio prompt.",
explanation = "Connect the phone to a computer with USB debugging on, and run this " + explanation = "Run this from a computer with USB debugging on, and Android trusts " +
"command. It tells Android to trust Gearslip with audio from now on. Skip it " + "Gearslip with your audio for good. Without it, you tap Start once each drive.",
"and everything still works, but you tap Start once each time you set off.", icon = glyph(Icons.Filled.Build),
optional = true,
command = "adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow", command = "adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow",
), ),
) )
@@ -252,10 +286,12 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!granted(context, arrayOf(Manifest.permission.WRITE_SECURE_SETTINGS))) { if (!granted(context, arrayOf(Manifest.permission.WRITE_SECURE_SETTINGS))) {
add( add(
GuidePage( GuidePage(
title = "Optional: keep notifications readable", title = "Readable notifications.",
explanation = "Android treats sending audio to the car like sharing your screen, and " + explanation = "While audio goes to the car, Android hides what notifications say, " +
"hides what your notifications say. This command switches that off only while " + "as if you were sharing your screen. This lets Gearslip show them during a " +
"music is going to the car, then puts it back.", "drive and hide them again after.",
icon = glyph(Icons.Filled.Build),
optional = true,
command = "adb shell pm grant ${BuildConfig.APPLICATION_ID} android.permission.WRITE_SECURE_SETTINGS", command = "adb shell pm grant ${BuildConfig.APPLICATION_ID} android.permission.WRITE_SECURE_SETTINGS",
), ),
) )
@@ -263,10 +299,11 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!hasCallScreeningRole(context)) { if (!hasCallScreeningRole(context)) {
add( add(
GuidePage( GuidePage(
title = "Optional: answer and decline calls", title = "Answer calls.",
explanation = "This sets Gearslip as your phone's caller ID and spam-blocking app, " + explanation = "Make Gearslip your caller ID app, and the car can answer and decline " +
"the same kind of consent a call-blocking app asks for. Without it, calls still " + "calls. Without it, calls still ring, but only the phone can answer them.",
"ring, but only the phone itself can answer or decline them.", icon = glyph(Icons.Filled.Call),
optional = true,
actionLabel = "Set up", actionLabel = "Set up",
onAction = onRequestCallScreening, onAction = onRequestCallScreening,
), ),
@@ -277,8 +314,9 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (SHOW_RECOMMENDED_APPS) { if (SHOW_RECOMMENDED_APPS) {
add( add(
GuidePage( GuidePage(
title = "Recommended apps", title = "Good company.",
explanation = RECOMMENDED_INTRO, explanation = RECOMMENDED_INTRO,
icon = { StarIcon(it) },
content = { RecommendedAppsList() }, content = { RecommendedAppsList() },
), ),
) )
@@ -325,31 +363,23 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
} ?: false } ?: false
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> val c = introColors()
Column( // The badges take turns through the phone's three accent colours, as the release intro does.
Modifier val (badge, onBadge) = listOf(c.primaryC to c.onPrimaryC, c.secondaryC to c.onSecondaryC, c.tertiaryC to c.onTertiaryC)[index % 3]
.fillMaxSize() val spin by rememberInfiniteTransition(label = "spin").animateFloat(
.padding(padding) 0f, 360f, infiniteRepeatable(tween(18_000, easing = LinearEasing)), label = "spin",
.padding(horizontal = 24.dp), )
horizontalAlignment = Alignment.CenterHorizontally,
) { BoxWithConstraints(Modifier.fillMaxSize().background(c.surface).systemBarsPadding()) {
Spacer(Modifier.height(24.dp)) val w = maxWidth
// A single page has no progress to show. // One percent of the screen's width, as in the intro, so it scales the same way.
if (pages.size > 1) { fun cq(n: Float): Dp = w * (n / 100f)
LinearProgressIndicator( val density = LocalDensity.current
progress = { (index + 1f) / pages.size }, fun cqSp(n: Float): TextUnit = with(density) { cq(n).toSp() }
modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge),
) Column(Modifier.fillMaxSize().padding(horizontal = cq(8f))) {
Spacer(Modifier.height(12.dp)) // The page scrolls above the buttons, so a tall one (the usage notes' choices) never
Text( // pushes them off the screen. Short pages sit in the middle.
"${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.
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
key(index) { key(index) {
Column( Column(
@@ -357,88 +387,114 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.heightIn(min = maxHeight) .heightIn(min = maxHeight)
.padding(vertical = 24.dp), .padding(vertical = cq(6f)),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) { Box(Modifier.size(cq(28f)).pop(entrance(index, 50, 800)), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { 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( Text(
page.title, "Already allowed.", color = c.primary,
style = MaterialTheme.typography.headlineSmall, style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.SemiBold),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
) )
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 { when {
page.permissions != null && alreadyGranted -> { page.permissions != null && !alreadyGranted -> {
Text( PillRow(c, ::cq, ::cqSp, back, "Allow") { launcher.launch(page.permissions) }
"Already allowed.", Pill(c, ::cqSp, "Skip for now", PillStyle.TEXT, Modifier.align(Alignment.CenterHorizontally), next)
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.onAction != null -> { page.onAction != null -> {
FilledTonalButton( Pill(c, ::cqSp, page.actionLabel ?: "Open", PillStyle.OUTLINED, Modifier.fillMaxWidth().padding(bottom = cq(3f)), page.onAction)
onClick = page.onAction, PillRow(c, ::cq, ::cqSp, back, "Next", next)
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")
}
}
} }
else -> PillRow(c, ::cq, ::cqSp, back, if (index == pages.lastIndex) "Done" else "Next", next)
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(cq(6f)))
} }
} }
} }
/** private enum class PillStyle { FILLED, OUTLINED, TEXT }
* 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 /** The intro's rounded buttons: filled for the step's main action, outlined beside it, bare text to skip. */
* click behavior - filling the rest of the width.
*/
@Composable @Composable
private fun PrimaryRow(showBack: Boolean, onBack: () -> Unit, content: @Composable (Modifier) -> Unit) { private fun Pill(c: IntroColors, cqSp: (Float) -> TextUnit, label: String, style: PillStyle, modifier: Modifier = Modifier, onClick: () -> Unit) {
if (showBack) { val shape = RoundedCornerShape(50)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { Box(
OutlinedButton( modifier
onClick = onBack, .clip(shape)
modifier = Modifier.weight(1f).height(52.dp), .then(
) { Text("Back") } when (style) {
content(Modifier.weight(1f).height(52.dp)) PillStyle.FILLED -> Modifier.background(c.primary)
} PillStyle.OUTLINED -> Modifier.border(BorderStroke(2.dp, c.outline), shape)
} else { PillStyle.TEXT -> Modifier
content(Modifier.fillMaxWidth().height(52.dp)) },
)
.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)
} }
} }
@@ -37,6 +37,10 @@ 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.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.platform.LocalContext
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -70,6 +74,31 @@ internal val USAGE_STATS_INTRO: AnnotatedString = buildAnnotatedString {
append(INTRO_END) 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. */ /** The usage notes card for the setup guide: the same rows Settings shows. */
@Composable @Composable
internal fun UsageStatsChoices(modifier: Modifier = Modifier) { internal fun UsageStatsChoices(modifier: Modifier = Modifier) {