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
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<String>? = 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<String>): 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<Gu
// --- Safety: always first, and never skipped by an already-granted check ---------
add(
GuidePage(
title = "Drive safely",
explanation = "This guide sets up the screen you will see mounted in your car. " +
"Gearslip is unofficial software: it is not made or reviewed by any carmaker, " +
"and it can fail without warning. Never interact with it while driving - set it " +
"up, mount the phone, then keep your attention on the road.",
title = "Drive safely.",
explanation = "Gearslip is unofficial. No carmaker made or checked it, and it can fail " +
"without warning. Set it up before you go, then keep your eyes on the road.",
icon = glyph(Icons.Filled.Warning),
),
)
@@ -132,10 +166,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) {
add(
GuidePage(
title = "Microphone",
explanation = "Gearslip needs the microphone for two jobs: passing your music " +
"app's sound to the car, and hearing you when you reply to a message or ask " +
"the assistant. It listens only then, and your speech stays on the phone.",
title = "Microphone.",
explanation = "Gearslip uses it to send your music to the car and to hear you when you " +
"ask for something or reply to a message. What you say stays on the phone.",
icon = { MicIcon(it) },
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))) {
add(
GuidePage(
title = "Location",
explanation = "The car's map and nearby places need to know where you are. " +
"Without it, the dashboard has no map card.",
title = "Location.",
explanation = "The car's map and nearby searches need it. Without it, the dashboard " +
"shows no map.",
icon = glyph(Icons.Filled.LocationOn),
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))) {
add(
GuidePage(
title = "Calendar",
explanation = "Gearslip reads your next event to show an agenda card on the car " +
"screen. It only reads; it never edits or deletes anything.",
title = "Calendar.",
explanation = "The car screen shows your next event. Gearslip only reads your calendar.",
icon = glyph(Icons.Filled.DateRange),
permissions = arrayOf(Manifest.permission.READ_CALENDAR),
),
)
@@ -170,11 +205,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!granted(context, callPermissions)) {
add(
GuidePage(
title = "Phone and contacts",
explanation = "When a call comes in, Gearslip matches the number against your " +
"contacts, so the car screen can show a name instead of just digits. The " +
"same permissions let the car screen's Phone app dial a number or call " +
"someone from your contacts.",
title = "Calls and contacts.",
explanation = "The car shows who's calling by name, and its Phone app can dial a " +
"number or call one of your contacts. Gearslip only reads them.",
icon = glyph(Icons.Filled.Phone),
permissions = callPermissions,
),
)
@@ -183,10 +217,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!granted(context, arrayOf(Manifest.permission.POST_NOTIFICATIONS))) {
add(
GuidePage(
title = "Notifications",
explanation = "Gearslip shows a notification while it's connected to the car or " +
"sending it audio, with a button to stop. Without this permission, Android " +
"hides it.",
title = "Notifications.",
explanation = "While Gearslip is connected, it shows a notification with a button " +
"to stop. Without this, Android hides it.",
icon = glyph(Icons.Filled.Notifications),
permissions = arrayOf(Manifest.permission.POST_NOTIFICATIONS),
),
)
@@ -194,10 +228,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!hasNotificationAccess(context)) {
add(
GuidePage(
title = "Notification access",
explanation = "The car screen shows your phone's notifications and lets you " +
"reply from there. Android only allows that once you grant Gearslip access, " +
"from the list this opens - it needs this to work at all.",
title = "Messages.",
explanation = "The car shows your messages, reads them aloud and lets you reply. " +
"Turn Gearslip on in the list this opens.",
icon = glyph(Icons.Filled.Email),
actionLabel = "Open notification access",
onAction = {
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
@@ -212,10 +246,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (androidAutoPackage != null) {
add(
GuidePage(
title = "Remove Android Auto",
explanation = "Android Auto is still installed. It claims the same USB " +
"connection Gearslip needs, so the two fight over it. Disable or uninstall " +
"it from the app info screen this opens.",
title = "Turn off Android Auto.",
explanation = "Android Auto is still installed, and it fights Gearslip for the car's " +
"USB connection. Disable or uninstall it on the screen this opens.",
icon = glyph(Icons.Filled.Close),
actionLabel = "Open Android Auto's app info",
onAction = {
context.startActivity(
@@ -229,11 +263,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
// --- Default handler ---------------------------------------------------------------
add(
GuidePage(
title = "Make Gearslip the default",
explanation = "Android has no separate default-app setting for a USB accessory like " +
"the car. It just launches whichever single app matches - which is Gearslip, " +
"now that Android Auto is out of the way. If a chooser still appears when you " +
"plug in, pick Gearslip and tap Always so Android stops asking.",
title = "Choose Gearslip.",
explanation = "If your phone asks which app to open when you plug into the car, pick " +
"Gearslip and tap Always.",
icon = glyph(Icons.Filled.Check),
),
)
@@ -241,10 +274,11 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!hasProjectMediaOp(context)) {
add(
GuidePage(
title = "Optional: trust audio every drive",
explanation = "Connect the phone to a computer with USB debugging on, and run this " +
"command. It tells Android to trust Gearslip with audio from now on. Skip it " +
"and everything still works, but you tap Start once each time you set off.",
title = "No audio prompt.",
explanation = "Run this from a computer with USB debugging on, and Android trusts " +
"Gearslip with your audio for good. Without it, you tap Start once each drive.",
icon = glyph(Icons.Filled.Build),
optional = true,
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))) {
add(
GuidePage(
title = "Optional: keep notifications readable",
explanation = "Android treats sending audio to the car like sharing your screen, and " +
"hides what your notifications say. This command switches that off only while " +
"music is going to the car, then puts it back.",
title = "Readable notifications.",
explanation = "While audio goes to the car, Android hides what notifications say, " +
"as if you were sharing your screen. This lets Gearslip show them during a " +
"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",
),
)
@@ -263,10 +299,11 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (!hasCallScreeningRole(context)) {
add(
GuidePage(
title = "Optional: answer and decline calls",
explanation = "This sets Gearslip as your phone's caller ID and spam-blocking app, " +
"the same kind of consent a call-blocking app asks for. Without it, calls still " +
"ring, but only the phone itself can answer or decline them.",
title = "Answer calls.",
explanation = "Make Gearslip your caller ID app, and the car can answer and decline " +
"calls. Without it, calls still ring, but only the phone can answer them.",
icon = glyph(Icons.Filled.Call),
optional = true,
actionLabel = "Set up",
onAction = onRequestCallScreening,
),
@@ -277,8 +314,9 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
if (SHOW_RECOMMENDED_APPS) {
add(
GuidePage(
title = "Recommended apps",
title = "Good company.",
explanation = RECOMMENDED_INTRO,
icon = { StarIcon(it) },
content = { RecommendedAppsList() },
),
)
@@ -325,31 +363,23 @@ fun SetupGuideScreen(onRequestCallScreening: () -> 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)
}
}
@@ -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) {