phone: each car settings group opens its own screen, under a bigger Car heading

This commit is contained in:
Seb3thehacker committed 2026-10-24 14:49:54 -07:00
1 parent bb07b95a9f
commit 0f2582f638
3 files changed
+118 -82

No files matched your search

@@ -37,7 +37,7 @@ import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.CertProvider
import app.seb3thehacker.gearslip.stats.UsageStats
private enum class Screen { INTRO, HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, USAGE_NOTES, HELP, WHATS_NEW, RECOMMENDED_APPS }
private enum class Screen { INTRO, HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, USAGE_NOTES, HELP, WHATS_NEW, RECOMMENDED_APPS, CAR_GROUP }
/** Three screens and a back stack of depth one: no navigation library needed. */
@Composable
@@ -64,6 +64,8 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
var settingsAtUsageNotes by rememberSaveable { mutableStateOf(false) }
// What's new and Connection help open from Home and from Settings; back returns to whichever.
var backTo by rememberSaveable { mutableStateOf(Screen.SETTINGS) }
// Which of the car's setting groups Home opened.
var carGroup by rememberSaveable { mutableStateOf(CarGroup.SCREEN) }
BackHandler(enabled = screen != Screen.HOME && screen != Screen.INTRO) {
screen = when (screen) {
Screen.RECOMMENDED_APPS, Screen.LOGS, Screen.CAR_PREVIEW -> Screen.SETTINGS
@@ -113,7 +115,9 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
onOpenCarPreview = { backTo = Screen.HOME; screen = Screen.CAR_PREVIEW },
onOpenLogs = { backTo = Screen.HOME; screen = Screen.LOGS },
onDisconnect = onDisconnect,
onOpenCarGroup = { carGroup = it; screen = Screen.CAR_GROUP },
)
Screen.CAR_GROUP -> CarGroupScreen(carGroup, onBack = { screen = Screen.HOME })
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = backTo })
Screen.LOGS -> LogsScreen(onBack = { screen = backTo })
Screen.SETTINGS -> SettingsScreen(
@@ -1,7 +1,5 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -10,7 +8,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
@@ -21,12 +22,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.car.AppTheme
@@ -37,13 +36,109 @@ import app.seb3thehacker.gearslip.car.NightMode
import app.seb3thehacker.gearslip.car.Units
import app.seb3thehacker.gearslip.car.theme.GsThemes
/** The car's setting groups, each opening its own screen from the phone's home screen. */
enum class CarGroup(val title: String) {
SCREEN("Screen"),
NAV_BAR("Nav bar and player"),
SOUND("Sound and voice"),
WARNINGS("Warnings"),
}
private val sizeChoices = listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large")
private val themeChoices = listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark")
private val nightChoices = listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night")
private val unitChoices = listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial", Units.METRIC to "Metric")
private val navBarChoices = listOf(
NavBarMode.SHOW to "Always show",
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
NavBarMode.HIDE to "Hide until tapped",
)
private fun openChoices() = listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label }
/**
* The car's settings on the phone's home screen, one collapsible card per group. They are the
* same settings as the car's own Settings app, so a change in either shows in both; the phone is
* just the easier place to make them when parked.
* The car's settings on the phone's home screen: one card per group with a summary of where it
* stands, each opening a screen of its own. They are the same settings as the car's own Settings
* app, so a change in either shows in both; the phone is just the easier place when parked.
*/
@Composable
internal fun HomeCarSettings(modifier: Modifier = Modifier) {
internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Modifier) {
val scale by CarSettings.scale.collectAsState()
val open by CarSettings.openOnConnect.collectAsState()
val navBar by CarSettings.navBarMode.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState()
val speakDirections by CarSettings.speakDirections.collectAsState()
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
val locationWarning by CarSettings.locationWarning.collectAsState()
val playerLeft by CarSettings.playerLeft.collectAsState()
val theme by CarSettings.appTheme.collectAsState()
fun summary(group: CarGroup) = when (group) {
CarGroup.SCREEN -> listOf(openChoices().label(open), sizeChoices.label(scale), themeChoices.label(theme))
CarGroup.NAV_BAR -> listOf(navBarChoices.label(navBar), "Player on the ${if (playerLeft) "left" else "right"}")
CarGroup.SOUND -> listOf(if (pipeAudio) "USB audio" else "Bluetooth audio", "Directions ${if (speakDirections) "on" else "off"}")
CarGroup.WARNINGS -> listOf(
"Bluetooth ${if (bluetoothWarning && !pipeAudio) "on" else "off"}",
"Location ${if (locationWarning) "on" else "off"}",
)
}.filter { it.isNotEmpty() }.joinToString(" · ")
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Car",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 4.dp),
)
SettingsCard {
CarGroup.entries.forEachIndexed { i, group ->
if (i > 0) SettingsDivider()
SettingsRow(
group.title,
Modifier.clickable { onOpen(group) },
subtitle = summary(group),
trailing = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) },
)
}
}
}
}
/** One group of the car's settings, on a screen of its own. */
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) {
androidx.compose.material3.Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
androidx.compose.material3.TopAppBar(
title = { Text(group.title) },
navigationIcon = {
androidx.compose.material3.IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
},
) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).padding(horizontal = 24.dp)
.verticalScroll(androidx.compose.foundation.rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SettingsCard { GroupRows(group) }
Text(
"These are the same settings as Settings in the car. A change here shows there too.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
@Composable
private fun GroupRows(group: CarGroup) {
val scale by CarSettings.scale.collectAsState()
val open by CarSettings.openOnConnect.collectAsState()
val night by CarSettings.nightMode.collectAsState()
@@ -60,45 +155,21 @@ internal fun HomeCarSettings(modifier: Modifier = Modifier) {
val theme by CarSettings.appTheme.collectAsState()
val uiTheme by CarSettings.uiTheme.collectAsState()
val openChoices = listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label }
val sizeChoices = listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large")
val themeChoices = listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark")
val lookChoices = GsThemes.builtIn.map { it.id to it.name }
val nightChoices = listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night")
val unitChoices = listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial", Units.METRIC to "Metric")
val navBarChoices = listOf(
NavBarMode.SHOW to "Always show",
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
NavBarMode.HIDE to "Hide until tapped",
)
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Car",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp),
)
Group(
"Screen",
summary = listOf(openChoices.label(open), sizeChoices.label(scale), themeChoices.label(theme)),
) {
Choice("Open on connect", openChoices, open, CarSettings::setOpenOnConnect)
when (group) {
CarGroup.SCREEN -> {
Choice("Open on connect", openChoices(), open, CarSettings::setOpenOnConnect)
SettingsDivider()
Choice("Size", sizeChoices, scale, CarSettings::setScale)
SettingsDivider()
Choice("App theme", themeChoices, theme, CarSettings::setAppTheme)
SettingsDivider()
Choice("Buttons", lookChoices, uiTheme, CarSettings::setUiTheme)
Choice("Buttons", GsThemes.builtIn.map { it.id to it.name }, uiTheme, CarSettings::setUiTheme)
SettingsDivider()
Choice("Map day/night", nightChoices, night, CarSettings::setNightMode)
SettingsDivider()
Choice("Units", unitChoices, units, CarSettings::setUnits)
}
Group(
"Nav bar and player",
summary = listOf(navBarChoices.label(navBar), "Player on the ${if (playerLeft) "left" else "right"}"),
) {
CarGroup.NAV_BAR -> {
Choice("Nav bar", navBarChoices, navBar, CarSettings::setNavBarMode)
SettingsDivider()
Choice("Player in the nav bar", listOf(false to "Skip buttons", true to "Song title"), playerTitle, CarSettings::setPlayerTitle)
@@ -107,10 +178,7 @@ internal fun HomeCarSettings(modifier: Modifier = Modifier) {
SettingsDivider()
Toggle("Autoplay on connect", autoplay, CarSettings::setAutoplay)
}
Group(
"Sound and voice",
summary = listOf(if (pipeAudio) "USB audio" else "Bluetooth audio", "Directions ${if (speakDirections) "on" else "off"}"),
) {
CarGroup.SOUND -> {
Choice("Car audio", listOf(false to "Bluetooth", true to "USB"), pipeAudio, CarSettings::setPipeAudio)
SettingsDivider()
Toggle(
@@ -127,13 +195,7 @@ internal fun HomeCarSettings(modifier: Modifier = Modifier) {
note = "On most phones that's Google's, which may send your voice to Google.",
)
}
Group(
"Warnings",
summary = listOf(
"Bluetooth ${if (bluetoothWarning && !pipeAudio) "on" else "off"}",
"Location ${if (locationWarning) "on" else "off"}",
),
) {
CarGroup.WARNINGS -> {
if (!pipeAudio) {
Toggle("Warn when Bluetooth isn't connected", bluetoothWarning, CarSettings::setBluetoothWarning)
SettingsDivider()
@@ -145,37 +207,6 @@ internal fun HomeCarSettings(modifier: Modifier = Modifier) {
private fun <T> List<Pair<T, String>>.label(value: T): String = firstOrNull { it.first == value }?.second.orEmpty()
/** One group: its name and a one-line summary, opening in place to the settings themselves. */
@Composable
private fun Group(title: String, summary: List<String>, content: @Composable () -> Unit) {
var open by rememberSaveable(title) { mutableStateOf(false) }
val turn by animateFloatAsState(if (open) 180f else 0f, label = "chevron")
SettingsCard {
Row(
Modifier.fillMaxWidth().clickable { open = !open }.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
if (!open) {
Text(
summary.filter { it.isNotEmpty() }.joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(Icons.Filled.KeyboardArrowDown, contentDescription = if (open) "Close" else "Open", modifier = Modifier.rotate(turn))
}
AnimatedVisibility(open) {
Column {
SettingsDivider()
content()
}
}
}
}
/** A setting with a few named choices: its current one shown, the rest a tap away. */
@Composable
private fun <T> Choice(title: String, choices: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
@@ -68,6 +68,7 @@ fun HomeScreen(
onOpenCarPreview: () -> Unit,
onOpenLogs: () -> Unit,
onDisconnect: () -> Unit,
onOpenCarGroup: (CarGroup) -> Unit,
) {
val status by SessionStatus.state.collectAsStateWithLifecycle()
val context = LocalContext.current
@@ -126,7 +127,7 @@ fun HomeScreen(
Spacer(Modifier.height(16.dp))
UpdateCard()
Spacer(Modifier.height(24.dp))
HomeCarSettings()
HomeCarSettings(onOpenCarGroup)
Spacer(Modifier.height(16.dp))
}