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)) } } }