From cf0c324d80dd0209163a7236ee550f3592dcf11d Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Mon, 5 Oct 2026 01:21:16 -0700 Subject: [PATCH] redesign the phone app's settings One look for every section, What's new and the setup guide at the top, and the setup guide's buttons stay on screen. Recommended apps is hidden for now. --- .../seb3thehacker/gearslip/ui/HelpScreen.kt | 7 +- .../seb3thehacker/gearslip/ui/HomeScreen.kt | 4 - .../gearslip/ui/RecommendedApps.kt | 3 + .../seb3thehacker/gearslip/ui/SettingsKit.kt | 116 +++++++++ .../gearslip/ui/SettingsScreen.kt | 229 ++++++++++-------- .../gearslip/ui/SetupGuideScreen.kt | 99 +++++--- 6 files changed, 325 insertions(+), 133 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsKit.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt index cf742f9..53ecf42 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt @@ -86,7 +86,7 @@ private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf( * here too so it can be reread without repeating the guide itself. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit, onReplayTutorial: () -> Unit) { +fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) { val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) } Scaffold( @@ -110,10 +110,7 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit, onReplayT .verticalScroll(rememberScrollState()), ) { Spacer(Modifier.height(8.dp)) - FilledTonalButton(onClick = onReplayTutorial, modifier = Modifier.fillMaxWidth().height(52.dp)) { - Text("Replay the guided tutorial") - } - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(16.dp)) Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { steps.forEachIndexed { index, step -> Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt index 7082c1a..a13d01f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Send -import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Settings @@ -49,7 +48,6 @@ fun HomeScreen( onOpenSettings: () -> Unit, onOpenCarPreview: () -> Unit, onOpenHelp: () -> Unit, - onOpenWhatsNew: () -> Unit, onDisconnect: () -> Unit, ) { val status by SessionStatus.state.collectAsStateWithLifecycle() @@ -115,8 +113,6 @@ fun HomeScreen( ActionRow(Icons.Filled.List, "Live logs", onOpenLogs) RowDivider() ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp) - RowDivider() - ActionRow(Icons.Filled.Star, "What's new", onOpenWhatsNew) if (BuildConfig.DEBUG) { val context = LocalContext.current RowDivider() diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt index df70321..2a84106 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt @@ -92,6 +92,9 @@ fun RecommendedAppsScreen(onBack: () -> Unit) { } } +/** Hidden for now: off, neither Settings nor the setup guide shows the list. The code stays. */ +internal const val SHOW_RECOMMENDED_APPS = false + internal const val RECOMMENDED_INTRO = "These free apps run on Gearslip's car screen. Each button opens the app's page on GitHub." diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsKit.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsKit.kt new file mode 100644 index 0000000..f58b383 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsKit.kt @@ -0,0 +1,116 @@ +package app.seb3thehacker.gearslip.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp + +/* + * The pieces every phone settings screen is built from, so they all share one shape, colour and + * spacing: a section label, a card holding rows, and the rows themselves, with a footer note + * under the card when a section needs one. + */ + +/** A titled group: label above, rows in one card, an optional note below. */ +@Composable +internal fun SettingsSection( + title: String? = null, + modifier: Modifier = Modifier, + header: AnnotatedString? = null, + footer: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier.fillMaxWidth()) { + title?.let { + Text( + it, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp), + ) + } + header?.let { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + ) + } + SettingsCard(content = content) + footer?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp), + ) + } + } +} + +/** The card behind a group of rows; also used on its own, as in the setup guide. */ +@Composable +internal fun SettingsCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.extraLarge, + modifier = modifier.fillMaxWidth(), + ) { + Column(content = content) + } +} + +/** + * One row: an optional icon, a title with an optional line under it, and whatever sits at the + * end (a switch, a checkbox, an arrow). [modifier] carries the click or toggle, so the whole + * row is the touch target. + */ +@Composable +internal fun SettingsRow( + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + icon: ImageVector? = null, + tint: Color = MaterialTheme.colorScheme.primary, + titleColor: Color = MaterialTheme.colorScheme.onSurface, + trailing: (@Composable () -> Unit)? = null, +) { + Row( + modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (icon != null) Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge, color = titleColor) + subtitle?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + trailing?.let { Box { it() } } + } +} + +/** A thin line between rows in one card. */ +@Composable +internal fun SettingsDivider() { + HorizontalDivider(Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt index 7bc37f0..bf5523a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -3,30 +3,37 @@ package app.seb3thehacker.gearslip.ui import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.foundation.layout.Row -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -39,22 +46,28 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import app.seb3thehacker.gearslip.AppSettings +import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.CertProvider +import app.seb3thehacker.gearslip.stats.UsageStats import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen(onBack: () -> Unit, onOpenRecommendedApps: () -> Unit) { +fun SettingsScreen( + onBack: () -> Unit, + onOpenRecommendedApps: () -> Unit, + onOpenWhatsNew: () -> Unit, + onReplayTutorial: () -> Unit, +) { Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { @@ -74,59 +87,78 @@ fun SettingsScreen(onBack: () -> Unit, onOpenRecommendedApps: () -> Unit) { .padding(padding) .padding(horizontal = 24.dp) .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(32.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { Spacer(Modifier.height(0.dp)) - GearslipEnabledSection() - AppsSection(onOpenRecommendedApps) + AboutSection(onOpenWhatsNew, onReplayTutorial) + ConnectionSection() + if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps) CertificateSection() + if (UsageStats.choosable) { + SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows(showNote = true) } + } Spacer(Modifier.height(16.dp)) } } } @Composable -private fun SectionTitle(text: String) { - Text( - text, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - ) +private fun Arrow() = Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, +) + +/** What's new and the setup guide, one tap from the top. */ +@Composable +private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit) { + SettingsSection { + SettingsRow( + "What's new", + Modifier.clickable(onClick = onOpenWhatsNew), + subtitle = "Version ${BuildConfig.VERSION_NAME}", + icon = Icons.Filled.Star, + trailing = { Arrow() }, + ) + SettingsDivider() + SettingsRow( + "Replay the setup guide", + Modifier.clickable(onClick = onReplayTutorial), + subtitle = "Go through the first-run steps again", + icon = Icons.Filled.Refresh, + trailing = { Arrow() }, + ) + } } /** Off, a stock Android Auto app or head unit answers the USB connection instead of Gearslip. */ @Composable -private fun GearslipEnabledSection() { +private fun ConnectionSection() { val context = LocalContext.current var enabled by remember { mutableStateOf(AppSettings.gearslipEnabled(context)) } - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - SectionTitle("Gearslip") - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text("Enabled", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - Switch(checked = enabled, onCheckedChange = { + SettingsSection("Connection") { + SettingsRow( + "Use Gearslip in the car", + Modifier.toggleable(enabled, role = Role.Switch) { enabled = it AppSettings.setGearslipEnabled(context, it) - }) - } - if (!enabled) { - Text( - "Off - stock Android Auto can take the USB connection instead.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + }, + subtitle = if (enabled) "Gearslip answers when you plug into a car" else "Off: Android Auto can take the connection instead", + icon = Icons.Filled.Settings, + trailing = { Switch(checked = enabled, onCheckedChange = null) }, + ) } } @Composable private fun AppsSection(onOpenRecommendedApps: () -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - SectionTitle("Apps") - OutlinedButton(onClick = onOpenRecommendedApps, modifier = Modifier.fillMaxWidth().height(52.dp)) { - Text("Recommended apps") - } + SettingsSection("Apps") { + SettingsRow( + "Recommended apps", + Modifier.clickable(onClick = onOpenRecommendedApps), + icon = Icons.Filled.Star, + trailing = { Arrow() }, + ) } } @@ -142,37 +174,49 @@ private fun CertificateSection() { var pendingUri by remember { mutableStateOf(null) } var notice by remember { mutableStateOf(null) } + var downloading by remember { mutableStateOf(false) } + var detailsOpen by remember { mutableStateOf(false) } val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri != null) pendingUri = uri } - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - SectionTitle("Certificate") - - Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) { - Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - val s = summary - if (s == null) { - Text("Checking…", style = MaterialTheme.typography.bodyMedium) - } else { - Text(s.headline, style = MaterialTheme.typography.bodyLarge) - s.subject?.let { Detail("Subject", it) } - s.issuer?.let { Detail("Issuer", it) } - s.validUntil?.let { Detail("Valid until", it) } + SettingsSection("Certificate", footer = notice ?: "Changes take effect on the next connection.") { + val s = summary + val loaded = s != null && s.kind != CertProvider.Kind.SELF_SIGNED + val hasDetails = s?.subject != null || s?.issuer != null + SettingsRow( + s?.headline ?: "Checking…", + if (hasDetails) Modifier.clickable { detailsOpen = !detailsOpen } else Modifier, + subtitle = s?.validUntil?.let { "Valid until $it" }, + icon = if (s == null || loaded) Icons.Filled.Lock else Icons.Filled.Warning, + tint = if (s == null || loaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + trailing = if (hasDetails) { + { + Icon( + if (detailsOpen) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = if (detailsOpen) "Hide details" else "Show details", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } + } else null, + ) + AnimatedVisibility(detailsOpen && hasDetails) { + Column( + Modifier.padding(start = 56.dp, end = 16.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + s?.subject?.let { Detail("Subject", it) } + s?.issuer?.let { Detail("Issuer", it) } } } - - Button(onClick = { picker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp)) { - Text("Import certificate (.p12)") - } - - FilledTonalButton( - onClick = { + SettingsDivider() + SettingsRow( + if (downloading) "Downloading…" else "Download certificate", + Modifier.clickable(enabled = !downloading) { + downloading = true scope.launch { - val result = withContext(Dispatchers.IO) { - runCatching { CertProvider.downloadAndImport(context) } - } + val result = withContext(Dispatchers.IO) { runCatching { CertProvider.downloadAndImport(context) } } + downloading = false result.onSuccess { notice = "Certificate downloaded." refresh++ @@ -181,39 +225,34 @@ private fun CertificateSection() { } } }, - modifier = Modifier.fillMaxWidth().height(52.dp), - ) { - Text("Download certificate") - } - - if (summary?.kind == CertProvider.Kind.IMPORTED) { - OutlinedButton( - onClick = { - CertProvider.removeImported(context) - notice = "Removed the imported certificate." - refresh++ - }, - modifier = Modifier.fillMaxWidth().height(52.dp), - ) { Text("Remove imported certificate") } - } - - if (summary?.kind == CertProvider.Kind.DOWNLOADED) { - OutlinedButton( - onClick = { - CertProvider.removeDownloaded(context) - notice = "Removed the downloaded certificate." - refresh++ - }, - modifier = Modifier.fillMaxWidth().height(52.dp), - ) { Text("Remove downloaded certificate") } - } - - notice?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } - Text( - "Changes take effect on the next connection.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + icon = Icons.Filled.Refresh, ) + SettingsDivider() + SettingsRow( + "Import a certificate", + Modifier.clickable { picker.launch(arrayOf("*/*")) }, + subtitle = "A .p12 file with its private key", + icon = Icons.Filled.Add, + ) + val removable = when (s?.kind) { + CertProvider.Kind.IMPORTED -> "Remove imported certificate" + CertProvider.Kind.DOWNLOADED -> "Remove downloaded certificate" + else -> null + } + if (removable != null) { + SettingsDivider() + SettingsRow( + removable, + Modifier.clickable { + if (s?.kind == CertProvider.Kind.IMPORTED) CertProvider.removeImported(context) else CertProvider.removeDownloaded(context) + notice = if (s?.kind == CertProvider.Kind.IMPORTED) "Removed the imported certificate." else "Removed the downloaded certificate." + refresh++ + }, + icon = Icons.Filled.Delete, + tint = MaterialTheme.colorScheme.error, + titleColor = MaterialTheme.colorScheme.error, + ) + } } pendingUri?.let { uri -> 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 68cd95f..74dfdb6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt @@ -1,5 +1,10 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.key import app.seb3thehacker.gearslip.BuildConfig import android.Manifest import android.app.AppOpsManager @@ -40,6 +45,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import app.seb3thehacker.gearslip.AppSettings @@ -47,6 +53,8 @@ import app.seb3thehacker.gearslip.call.CarCalls import app.seb3thehacker.gearslip.car.Prefetch import app.seb3thehacker.gearslip.host.AndroidAuto import app.seb3thehacker.gearslip.notify.GearslipNotificationListener +import app.seb3thehacker.gearslip.stats.UsageStats +import androidx.compose.runtime.DisposableEffect /** * One page of the guide. [permissions] drives a system permission dialog; [command] is an adb @@ -56,7 +64,8 @@ import app.seb3thehacker.gearslip.notify.GearslipNotificationListener */ private class GuidePage( val title: String, - val explanation: String, + /** Plain text, or an [AnnotatedString] when part of it needs to stand out. */ + val explanation: CharSequence, val permissions: Array? = null, val command: String? = null, val actionLabel: String? = null, @@ -265,14 +274,34 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List Unit, onDone: () -> Unit) { .padding(padding) .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, ) { + Spacer(Modifier.height(24.dp)) LinearProgressIndicator( progress = { (index + 1f) / pages.size }, modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge), @@ -332,28 +361,39 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) { fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(Modifier.height(48.dp)) - - Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) { - Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - page.title, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - page.explanation, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - page.command?.let { CopyableCommand(it) } - page.content?.invoke() + // 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()) { + key(index) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .padding(vertical = 24.dp), + verticalArrangement = Arrangement.Center, + ) { + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) { + Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + page.title, + style = MaterialTheme.typography.headlineSmall, + 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() + } + } + } } } - Spacer(Modifier.height(32.dp)) - when { page.permissions != null && alreadyGranted -> { Text( @@ -391,6 +431,7 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) { } } } + Spacer(Modifier.height(24.dp)) } } }