mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
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:
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user