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.
This commit is contained in:
Seb3thehacker committed 2026-10-05 01:21:16 -07:00
1 parent 5a3bbe82a0
commit cf0c324d80
6 files changed
+325 -133

No files matched your search

@@ -86,7 +86,7 @@ private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
* here too so it can be reread without repeating the guide itself. */ * here too so it can be reread without repeating the guide itself. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit, onReplayTutorial: () -> Unit) { fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) } val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) }
Scaffold( Scaffold(
@@ -110,10 +110,7 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit, onReplayT
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
FilledTonalButton(onClick = onReplayTutorial, modifier = Modifier.fillMaxWidth().height(52.dp)) { Spacer(Modifier.height(16.dp))
Text("Replay the guided tutorial")
}
Spacer(Modifier.height(24.dp))
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
steps.forEachIndexed { index, step -> steps.forEachIndexed { index, step ->
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
@@ -17,7 +17,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Send 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.List
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@@ -49,7 +48,6 @@ fun HomeScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenCarPreview: () -> Unit, onOpenCarPreview: () -> Unit,
onOpenHelp: () -> Unit, onOpenHelp: () -> Unit,
onOpenWhatsNew: () -> Unit,
onDisconnect: () -> Unit, onDisconnect: () -> Unit,
) { ) {
val status by SessionStatus.state.collectAsStateWithLifecycle() val status by SessionStatus.state.collectAsStateWithLifecycle()
@@ -115,8 +113,6 @@ fun HomeScreen(
ActionRow(Icons.Filled.List, "Live logs", onOpenLogs) ActionRow(Icons.Filled.List, "Live logs", onOpenLogs)
RowDivider() RowDivider()
ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp) ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
RowDivider()
ActionRow(Icons.Filled.Star, "What's new", onOpenWhatsNew)
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
val context = LocalContext.current val context = LocalContext.current
RowDivider() RowDivider()
@@ -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 = internal const val RECOMMENDED_INTRO =
"These free apps run on Gearslip's car screen. Each button opens the app's page on GitHub." "These free apps run on Gearslip's car screen. Each button opens the app's page on GitHub."
@@ -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)
}
@@ -3,30 +3,37 @@ package app.seb3thehacker.gearslip.ui
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
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.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme 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.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -39,22 +46,28 @@ import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.CertProvider import app.seb3thehacker.gearslip.CertProvider
import app.seb3thehacker.gearslip.stats.UsageStats
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen(onBack: () -> Unit, onOpenRecommendedApps: () -> Unit) { fun SettingsScreen(
onBack: () -> Unit,
onOpenRecommendedApps: () -> Unit,
onOpenWhatsNew: () -> Unit,
onReplayTutorial: () -> Unit,
) {
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
topBar = { topBar = {
@@ -74,59 +87,78 @@ fun SettingsScreen(onBack: () -> Unit, onOpenRecommendedApps: () -> Unit) {
.padding(padding) .padding(padding)
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(32.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
Spacer(Modifier.height(0.dp)) Spacer(Modifier.height(0.dp))
GearslipEnabledSection() AboutSection(onOpenWhatsNew, onReplayTutorial)
AppsSection(onOpenRecommendedApps) ConnectionSection()
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
CertificateSection() CertificateSection()
if (UsageStats.choosable) {
SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows(showNote = true) }
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
} }
} }
} }
@Composable @Composable
private fun SectionTitle(text: String) { private fun Arrow() = Icon(
Text( Icons.AutoMirrored.Filled.KeyboardArrowRight,
text, contentDescription = null,
style = MaterialTheme.typography.titleMedium, tint = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold, )
color = MaterialTheme.colorScheme.primary,
) /** 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. */ /** Off, a stock Android Auto app or head unit answers the USB connection instead of Gearslip. */
@Composable @Composable
private fun GearslipEnabledSection() { private fun ConnectionSection() {
val context = LocalContext.current val context = LocalContext.current
var enabled by remember { mutableStateOf(AppSettings.gearslipEnabled(context)) } var enabled by remember { mutableStateOf(AppSettings.gearslipEnabled(context)) }
SettingsSection("Connection") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SettingsRow(
SectionTitle("Gearslip") "Use Gearslip in the car",
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Modifier.toggleable(enabled, role = Role.Switch) {
Text("Enabled", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Switch(checked = enabled, onCheckedChange = {
enabled = it enabled = it
AppSettings.setGearslipEnabled(context, it) AppSettings.setGearslipEnabled(context, it)
}) },
} subtitle = if (enabled) "Gearslip answers when you plug into a car" else "Off: Android Auto can take the connection instead",
if (!enabled) { icon = Icons.Filled.Settings,
Text( trailing = { Switch(checked = enabled, onCheckedChange = null) },
"Off - stock Android Auto can take the USB connection instead.", )
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
} }
@Composable @Composable
private fun AppsSection(onOpenRecommendedApps: () -> Unit) { private fun AppsSection(onOpenRecommendedApps: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SettingsSection("Apps") {
SectionTitle("Apps") SettingsRow(
OutlinedButton(onClick = onOpenRecommendedApps, modifier = Modifier.fillMaxWidth().height(52.dp)) { "Recommended apps",
Text("Recommended apps") Modifier.clickable(onClick = onOpenRecommendedApps),
} icon = Icons.Filled.Star,
trailing = { Arrow() },
)
} }
} }
@@ -142,37 +174,49 @@ private fun CertificateSection() {
var pendingUri by remember { mutableStateOf<Uri?>(null) } var pendingUri by remember { mutableStateOf<Uri?>(null) }
var notice by remember { mutableStateOf<String?>(null) } var notice by remember { mutableStateOf<String?>(null) }
var downloading by remember { mutableStateOf(false) }
var detailsOpen by remember { mutableStateOf(false) }
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) pendingUri = uri if (uri != null) pendingUri = uri
} }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { SettingsSection("Certificate", footer = notice ?: "Changes take effect on the next connection.") {
SectionTitle("Certificate") val s = summary
val loaded = s != null && s.kind != CertProvider.Kind.SELF_SIGNED
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) { val hasDetails = s?.subject != null || s?.issuer != null
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { SettingsRow(
val s = summary s?.headline ?: "Checking…",
if (s == null) { if (hasDetails) Modifier.clickable { detailsOpen = !detailsOpen } else Modifier,
Text("Checking…", style = MaterialTheme.typography.bodyMedium) subtitle = s?.validUntil?.let { "Valid until $it" },
} else { icon = if (s == null || loaded) Icons.Filled.Lock else Icons.Filled.Warning,
Text(s.headline, style = MaterialTheme.typography.bodyLarge) tint = if (s == null || loaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
s.subject?.let { Detail("Subject", it) } trailing = if (hasDetails) {
s.issuer?.let { Detail("Issuer", it) } {
s.validUntil?.let { Detail("Valid until", it) } 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) }
} }
} }
SettingsDivider()
Button(onClick = { picker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp)) { SettingsRow(
Text("Import certificate (.p12)") if (downloading) "Downloading…" else "Download certificate",
} Modifier.clickable(enabled = !downloading) {
downloading = true
FilledTonalButton(
onClick = {
scope.launch { scope.launch {
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) { runCatching { CertProvider.downloadAndImport(context) } }
runCatching { CertProvider.downloadAndImport(context) } downloading = false
}
result.onSuccess { result.onSuccess {
notice = "Certificate downloaded." notice = "Certificate downloaded."
refresh++ refresh++
@@ -181,39 +225,34 @@ private fun CertificateSection() {
} }
} }
}, },
modifier = Modifier.fillMaxWidth().height(52.dp), icon = Icons.Filled.Refresh,
) {
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,
) )
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 -> pendingUri?.let { uri ->
@@ -1,5 +1,10 @@
package app.seb3thehacker.gearslip.ui 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 app.seb3thehacker.gearslip.BuildConfig
import android.Manifest import android.Manifest
import android.app.AppOpsManager import android.app.AppOpsManager
@@ -40,6 +45,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.AppSettings 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.car.Prefetch
import app.seb3thehacker.gearslip.host.AndroidAuto import app.seb3thehacker.gearslip.host.AndroidAuto
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener 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 * 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( private class GuidePage(
val title: String, 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<String>? = null, val permissions: Array<String>? = null,
val command: String? = null, val command: String? = null,
val actionLabel: String? = null, val actionLabel: String? = null,
@@ -265,14 +274,34 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
) )
} }
// --- Last: apps worth installing, once everything above is set up --------------- // --- Apps worth installing, once everything above is set up -----------------------
add( if (SHOW_RECOMMENDED_APPS) {
GuidePage( add(
title = "Recommended apps", GuidePage(
explanation = RECOMMENDED_INTRO, title = "Recommended apps",
content = { RecommendedAppsList() }, explanation = RECOMMENDED_INTRO,
), content = { RecommendedAppsList() },
) ),
)
}
// --- The very end, every time: usage notes. The first time through, leaving the page
// without turning them on is a no; on a replay, the switch shows what was chosen before.
// Dev builds always send, so they skip the page.
if (UsageStats.choosable) {
add(
GuidePage(
title = USAGE_STATS_TITLE,
explanation = USAGE_STATS_INTRO,
content = {
UsageStatsChoices()
DisposableEffect(Unit) {
onDispose { if (!UsageStats.asked(context)) UsageStats.set(context, false) }
}
},
),
)
}
} }
/** /**
@@ -319,8 +348,8 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
.padding(padding) .padding(padding)
.padding(horizontal = 24.dp), .padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) { ) {
Spacer(Modifier.height(24.dp))
LinearProgressIndicator( LinearProgressIndicator(
progress = { (index + 1f) / pages.size }, progress = { (index + 1f) / pages.size },
modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge), modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge),
@@ -332,28 +361,39 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.height(48.dp)) // 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.
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) { BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { key(index) {
Text( Column(
page.title, Modifier
style = MaterialTheme.typography.headlineSmall, .fillMaxWidth()
fontWeight = FontWeight.Bold, .verticalScroll(rememberScrollState())
color = MaterialTheme.colorScheme.onSurface, .heightIn(min = maxHeight)
) .padding(vertical = 24.dp),
Text( verticalArrangement = Arrangement.Center,
page.explanation, ) {
style = MaterialTheme.typography.bodyLarge, Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) {
color = MaterialTheme.colorScheme.onSurfaceVariant, Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
) Text(
page.command?.let { CopyableCommand(it) } page.title,
page.content?.invoke() 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 { when {
page.permissions != null && alreadyGranted -> { page.permissions != null && alreadyGranted -> {
Text( Text(
@@ -391,6 +431,7 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
} }
} }
} }
Spacer(Modifier.height(24.dp))
} }
} }
} }