mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
phone: the app's screens take their text from strings.xml, ready for translation, with per-app language support and pseudo-locales in debug builds
This commit is contained in:
1 parent
dc9514dcfa
commit
02618dbcb9
24 files changed
+910
-451
No files matched your search
@@ -26,7 +26,7 @@ object WorkedPrompt {
|
||||
private var loaded = false
|
||||
private val flow = MutableStateFlow<String?>(null)
|
||||
|
||||
/** The car's name while the ask is waiting for an answer, otherwise null. */
|
||||
/** The car's name while the ask is waiting for an answer (empty when the car didn't say), otherwise null. */
|
||||
fun pending(context: Context): StateFlow<String?> {
|
||||
if (!loaded) {
|
||||
loaded = true
|
||||
@@ -99,17 +99,14 @@ object WorkedPrompt {
|
||||
private fun saved(context: Context): JSONObject? =
|
||||
prefs(context).getString(KEY_CAR, null)?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
|
||||
/** "your 2019 Clio", "your Renault Media Nav", or "your car" when it didn't say. */
|
||||
/** "2019 Clio", "Renault Media Nav", or empty when the car didn't say. */
|
||||
private fun carName(car: JSONObject): String {
|
||||
val model = car.optString("car").trim()
|
||||
val year = car.optString("year").trim().takeIf { it.isNotEmpty() && it != "0" }
|
||||
val unit = "${car.optString("make")} ${car.optString("model")}".trim()
|
||||
return when {
|
||||
model.isNotEmpty() -> "your " + listOfNotNull(year, model).joinToString(" ")
|
||||
unit.isNotEmpty() -> "your $unit"
|
||||
else -> "your car"
|
||||
model.isNotEmpty() -> listOfNotNull(year, model).joinToString(" ")
|
||||
else -> unit
|
||||
}
|
||||
}
|
||||
|
||||
const val TITLE_START = "Gearslip worked with "
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
@@ -59,14 +61,14 @@ internal object GearslipLinks {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("monero:$MONERO")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}.onFailure {
|
||||
Toast.makeText(context, "No Monero wallet app found. Copy the address instead.", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(context, context.getString(R.string.about_no_wallet), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun copyMonero(context: Context) {
|
||||
context.getSystemService(ClipboardManager::class.java)
|
||||
?.setPrimaryClip(ClipData.newPlainText("Monero address", MONERO))
|
||||
Toast.makeText(context, "Monero address copied", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.about_monero_copied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +86,7 @@ internal fun DonateRows() {
|
||||
SettingsRow(
|
||||
"Liberapay",
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.LIBERAPAY) },
|
||||
subtitle = "Give weekly or monthly",
|
||||
subtitle = stringResource(R.string.about_give_regularly),
|
||||
icon = Icons.Filled.Favorite,
|
||||
trailing = arrow,
|
||||
)
|
||||
@@ -92,16 +94,16 @@ internal fun DonateRows() {
|
||||
SettingsRow(
|
||||
"Buy Me a Coffee",
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.BUY_ME_A_COFFEE) },
|
||||
subtitle = "Give once or monthly, by card. Buy Me a Coffee keeps 5%",
|
||||
subtitle = stringResource(R.string.about_bmac_sub),
|
||||
icon = Icons.Filled.Favorite,
|
||||
trailing = arrow,
|
||||
)
|
||||
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Give once",
|
||||
stringResource(R.string.about_give_once),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.PAYMENT_LINK) },
|
||||
subtitle = "Any amount, by card",
|
||||
subtitle = stringResource(R.string.about_any_amount),
|
||||
icon = Icons.Filled.Favorite,
|
||||
trailing = arrow,
|
||||
)
|
||||
@@ -112,7 +114,7 @@ internal fun DonateRows() {
|
||||
SettingsRow(
|
||||
"Monero",
|
||||
Modifier.clickable { moneroOpen = true },
|
||||
subtitle = "Open your wallet or copy the address",
|
||||
subtitle = stringResource(R.string.about_monero_sub),
|
||||
icon = Icons.Filled.Favorite,
|
||||
trailing = arrow,
|
||||
)
|
||||
@@ -127,10 +129,10 @@ internal fun MoneroDialog(onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
|
||||
title = { Text("Support with Monero") },
|
||||
title = { Text(stringResource(R.string.about_monero_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Send any amount to this address from your Monero wallet.")
|
||||
Text(stringResource(R.string.about_monero_body))
|
||||
SelectionContainer {
|
||||
Text(
|
||||
GearslipLinks.MONERO,
|
||||
@@ -143,13 +145,13 @@ internal fun MoneroDialog(onDismiss: () -> Unit) {
|
||||
confirmButton = {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { GearslipLinks.openMoneroWallet(context) }, Modifier.fillMaxWidth()) {
|
||||
Text("Open in wallet")
|
||||
Text(stringResource(R.string.about_open_wallet))
|
||||
}
|
||||
OutlinedButton(onClick = { GearslipLinks.copyMonero(context) }, Modifier.fillMaxWidth()) {
|
||||
Text("Copy address")
|
||||
Text(stringResource(R.string.about_copy_address))
|
||||
}
|
||||
TextButton(onClick = onDismiss, Modifier.fillMaxWidth()) {
|
||||
Text("Close")
|
||||
Text(stringResource(R.string.close))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -168,25 +170,24 @@ internal fun AboutLinkRows() {
|
||||
)
|
||||
}
|
||||
SettingsRow(
|
||||
"Join the chat on Matrix",
|
||||
stringResource(R.string.about_matrix),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.MATRIX) },
|
||||
subtitle = "Talk with other drivers about cars, apps, and Gearslip",
|
||||
subtitle = stringResource(R.string.about_matrix_sub),
|
||||
icon = Icons.AutoMirrored.Filled.Send,
|
||||
trailing = arrow,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Source code",
|
||||
stringResource(R.string.about_source),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.SOURCE) },
|
||||
subtitle = "Read or change Gearslip's code under the AGPL",
|
||||
subtitle = stringResource(R.string.about_source_sub),
|
||||
icon = Icons.Filled.Build,
|
||||
trailing = arrow,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Voice credits",
|
||||
subtitle = "Speech recognition by sherpa-onnx (Apache 2.0), with Kroko ASR by Banafo " +
|
||||
"(CC BY-SA 4.0), and Parakeet and Canary by NVIDIA (CC BY 4.0)",
|
||||
stringResource(R.string.about_voice_credits),
|
||||
subtitle = stringResource(R.string.about_voice_credits_sub),
|
||||
icon = Icons.Filled.Favorite,
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -24,27 +26,25 @@ fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
|
||||
var showCommand by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepAsking,
|
||||
title = { Text("Play car audio over Bluetooth?") },
|
||||
title = { Text(stringResource(R.string.audio_bt_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Android treats music sent over USB like sharing your screen, so it asks every " +
|
||||
"drive. Over Bluetooth it never asks. You can change this later in Settings.",
|
||||
stringResource(R.string.audio_bt_body),
|
||||
)
|
||||
if (showCommand) {
|
||||
Text(
|
||||
"Or keep USB and stop the question for good: plug the phone into a " +
|
||||
"computer with USB debugging on, and run this once.",
|
||||
stringResource(R.string.audio_keep_usb),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
CopyableCommand("adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow")
|
||||
} else {
|
||||
TextButton(onClick = { showCommand = true }) { Text("Never ask, using a computer") }
|
||||
TextButton(onClick = { showCommand = true }) { Text(stringResource(R.string.audio_never_ask)) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onUseBluetooth) { Text("Use Bluetooth") } },
|
||||
dismissButton = { TextButton(onClick = onKeepAsking) { Text("Keep asking") } },
|
||||
confirmButton = { TextButton(onClick = onUseBluetooth) { Text(stringResource(R.string.audio_use_bt)) } },
|
||||
dismissButton = { TextButton(onClick = onKeepAsking) { Text(stringResource(R.string.audio_keep_asking)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,15 +57,13 @@ fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
|
||||
fun MicBlockedDialog(onOpenSettings: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Car audio needs the microphone permission") },
|
||||
title = { Text(stringResource(R.string.audio_mic_title)) },
|
||||
text = {
|
||||
Text(
|
||||
"Android lets an app send other apps' sound to the car only if it has the " +
|
||||
"microphone permission. It's turned off for Gearslip, so music plays on the " +
|
||||
"phone. Turn it on under Permissions, then Microphone.",
|
||||
stringResource(R.string.audio_mic_body),
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onOpenSettings) { Text("Open app settings") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Not now") } },
|
||||
confirmButton = { TextButton(onClick = onOpenSettings) { Text(stringResource(R.string.audio_open_settings)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.worked_not_now)) } },
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -41,19 +43,18 @@ internal fun CarScreenSection() {
|
||||
|
||||
if (editingCar) {
|
||||
SizeDialog(
|
||||
title = "Car screen size",
|
||||
note = "Use this if the car's picture looks stretched. Enter the screen's real size, " +
|
||||
"such as 1280x480. It takes effect the next time you connect.",
|
||||
title = stringResource(R.string.screen_car_size),
|
||||
note = stringResource(R.string.screen_car_size_note),
|
||||
initial = size,
|
||||
clearLabel = "Use the car's",
|
||||
clearLabel = stringResource(R.string.screen_use_cars),
|
||||
onDismiss = { editingCar = false },
|
||||
onSave = { CarSettings.setScreenSize(it); editingCar = false },
|
||||
)
|
||||
}
|
||||
if (editingPreview) {
|
||||
SizeDialog(
|
||||
title = "Preview size",
|
||||
note = "The size the car preview draws at, such as 1920x720.",
|
||||
title = stringResource(R.string.screen_preview_size),
|
||||
note = stringResource(R.string.screen_preview_note),
|
||||
initial = previewCustom,
|
||||
clearLabel = null,
|
||||
onDismiss = { editingPreview = false },
|
||||
@@ -65,62 +66,62 @@ internal fun CarScreenSection() {
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
"Car screen",
|
||||
footer = "Gearslip uses the size the car reports unless you set one.",
|
||||
stringResource(R.string.screen_car_screen),
|
||||
footer = stringResource(R.string.screen_footer),
|
||||
) {
|
||||
SettingsRow(
|
||||
"Screen size",
|
||||
stringResource(R.string.screen_size),
|
||||
Modifier.clickable { editingCar = true },
|
||||
subtitle = size?.let { "$it, set by you" } ?: "From the car",
|
||||
subtitle = size?.let { stringResource(R.string.screen_set_by_you, it.toString()) } ?: stringResource(R.string.screen_from_car),
|
||||
)
|
||||
if (size != null) {
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Crop the edges",
|
||||
stringResource(R.string.screen_crop),
|
||||
Modifier.selectable(fit == ScreenFit.CROP, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.CROP) },
|
||||
subtitle = "For a car that cuts off the top and bottom, or the sides",
|
||||
subtitle = stringResource(R.string.screen_crop_sub),
|
||||
trailing = { RadioButton(selected = fit == ScreenFit.CROP, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Squash to fit",
|
||||
stringResource(R.string.screen_squash),
|
||||
Modifier.selectable(fit == ScreenFit.SQUASH, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.SQUASH) },
|
||||
subtitle = "For a car that squeezes the whole picture, so circles look like ovals",
|
||||
subtitle = stringResource(R.string.screen_squash_sub),
|
||||
trailing = { RadioButton(selected = fit == ScreenFit.SQUASH, onClick = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection("Car preview size") {
|
||||
SettingsSection(stringResource(R.string.screen_preview_title)) {
|
||||
SettingsRow(
|
||||
"Last car",
|
||||
stringResource(R.string.screen_last_car),
|
||||
Modifier.selectable(preview == PreviewSize.LAST_CAR, role = Role.RadioButton) {
|
||||
CarSettings.setPreviewSize(PreviewSize.LAST_CAR)
|
||||
},
|
||||
subtitle = "The size of the last car you connected to",
|
||||
subtitle = stringResource(R.string.screen_last_car_sub),
|
||||
trailing = { RadioButton(selected = preview == PreviewSize.LAST_CAR, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"This screen",
|
||||
stringResource(R.string.screen_this_screen),
|
||||
Modifier.selectable(preview == PreviewSize.FILL, role = Role.RadioButton) {
|
||||
CarSettings.setPreviewSize(PreviewSize.FILL)
|
||||
},
|
||||
subtitle = "Fills this device's screen, for a head unit running Gearslip itself",
|
||||
subtitle = stringResource(R.string.screen_this_screen_sub),
|
||||
trailing = { RadioButton(selected = preview == PreviewSize.FILL, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Custom",
|
||||
stringResource(R.string.screen_custom),
|
||||
Modifier.selectable(preview == PreviewSize.CUSTOM, role = Role.RadioButton) {
|
||||
if (previewCustom == null) editingPreview = true else CarSettings.setPreviewSize(PreviewSize.CUSTOM)
|
||||
},
|
||||
subtitle = previewCustom?.toString() ?: "Enter a size",
|
||||
subtitle = previewCustom?.toString() ?: stringResource(R.string.screen_enter_size),
|
||||
trailing = { RadioButton(selected = preview == PreviewSize.CUSTOM, onClick = null) },
|
||||
)
|
||||
if (preview == PreviewSize.CUSTOM) {
|
||||
SettingsDivider()
|
||||
SettingsRow("Change the custom size", Modifier.clickable { editingPreview = true })
|
||||
SettingsRow(stringResource(R.string.screen_change_custom), Modifier.clickable { editingPreview = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,14 +146,14 @@ private fun SizeDialog(
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(note)
|
||||
OutlinedTextField(width, { width = it.filter(Char::isDigit) }, label = { Text("Width") }, singleLine = true, keyboardOptions = number)
|
||||
OutlinedTextField(height, { height = it.filter(Char::isDigit) }, label = { Text("Height") }, singleLine = true, keyboardOptions = number)
|
||||
OutlinedTextField(width, { width = it.filter(Char::isDigit) }, label = { Text(stringResource(R.string.screen_width)) }, singleLine = true, keyboardOptions = number)
|
||||
OutlinedTextField(height, { height = it.filter(Char::isDigit) }, label = { Text(stringResource(R.string.screen_height)) }, singleLine = true, keyboardOptions = number)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onSave(parsed) }, enabled = parsed != null) { Text("Save") } },
|
||||
confirmButton = { TextButton(onClick = { onSave(parsed) }, enabled = parsed != null) { Text(stringResource(R.string.save)) } },
|
||||
dismissButton = {
|
||||
if (clearLabel != null) TextButton(onClick = { onSave(null) }) { Text(clearLabel) }
|
||||
else TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
else TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -80,7 +82,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
"Certificate Setup",
|
||||
stringResource(R.string.certsetup_title),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
@@ -88,9 +90,9 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
if (installed) {
|
||||
"Cars accept only Google-issued certificates, and you have one loaded."
|
||||
stringResource(R.string.certsetup_loaded)
|
||||
} else {
|
||||
"Cars accept only Google-issued certificates. Gearslip doesn't include one, so choose where yours comes from."
|
||||
stringResource(R.string.certsetup_choose)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -100,14 +102,14 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
|
||||
if (installed) {
|
||||
Text(
|
||||
if (manualProgress) "Certificate imported." else "Certificate installed.",
|
||||
if (manualProgress) stringResource(R.string.cert_imported) else stringResource(R.string.certsetup_installed),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text("Continue")
|
||||
Text(stringResource(R.string.certsetup_continue))
|
||||
}
|
||||
Spacer(Modifier.height(40.dp))
|
||||
return@Column
|
||||
@@ -130,11 +132,10 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text("Download from aasdk")
|
||||
Text(stringResource(R.string.cert_download))
|
||||
}
|
||||
Text(
|
||||
"The open-source aasdk project publishes this certificate at github.com/opencardev/aasdk. " +
|
||||
"Most cars accept it; some from around 2020 on don't.",
|
||||
stringResource(R.string.certsetup_aasdk_sub),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -146,7 +147,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
Box(Modifier.fillMaxWidth().height(56.dp), contentAlignment = Alignment.Center) {
|
||||
DownloadingRow {
|
||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text("Downloading…", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(stringResource(R.string.cert_downloading), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,7 +158,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"Download failed",
|
||||
stringResource(R.string.certsetup_failed),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
@@ -168,7 +169,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Button(onClick = { downloadState = DownloadState.Idle }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Retry")
|
||||
Text(stringResource(R.string.certsetup_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,9 +181,9 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
OutlinedButton(
|
||||
onClick = { importLauncher.launch(arrayOf("*/*")) },
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) { Text("Import my own file") }
|
||||
) { Text(stringResource(R.string.cert_import)) }
|
||||
Text(
|
||||
"A .p12 file with its private key, from wherever you choose.",
|
||||
stringResource(R.string.certsetup_import_sub),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -197,7 +198,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) {
|
||||
Text("Skip car connection setup")
|
||||
Text(stringResource(R.string.certsetup_skip))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
@@ -216,7 +217,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
pendingUri = null
|
||||
manualProgress = true
|
||||
}.onFailure {
|
||||
reportError("Wrong password or not a valid PKCS#12 file with a private key.")
|
||||
reportError(context.getString(R.string.cert_import_failed))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -224,7 +225,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A tight horizontal row for the inline "Downloading…" spinner state. */
|
||||
/** A tight horizontal row for the inline stringResource(R.string.cert_downloading) spinner state. */
|
||||
@Composable
|
||||
private fun DownloadingRow(content: @Composable RowScope.() -> Unit) {
|
||||
Row(
|
||||
@@ -248,20 +249,20 @@ private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) -
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Certificate password") },
|
||||
title = { Text(stringResource(R.string.cert_password_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it; error = null },
|
||||
singleLine = true,
|
||||
label = { Text("Password") },
|
||||
label = { Text(stringResource(R.string.cert_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
isError = error != null,
|
||||
supportingText = { Text(error ?: "Leave empty if the file has none.") },
|
||||
supportingText = { Text(error ?: stringResource(R.string.cert_password_hint)) },
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text(stringResource(R.string.cert_import_button)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.CertProvider
|
||||
import java.text.DateFormat
|
||||
@@ -28,20 +29,20 @@ data class CertSummary(
|
||||
val identity = CertProvider.loadSupplied(context)
|
||||
?: return CertSummary(
|
||||
CertProvider.Kind.SELF_SIGNED,
|
||||
"None loaded (self-signed; cars reject it)",
|
||||
context.getString(R.string.cert_none),
|
||||
null, null, null,
|
||||
)
|
||||
val cert = identity.certificate
|
||||
val name = friendlyName(cert.subjectX500Principal.name)
|
||||
val prefix = when (identity.kind) {
|
||||
CertProvider.Kind.IMPORTED -> "Imported"
|
||||
CertProvider.Kind.DOWNLOADED -> "Downloaded"
|
||||
CertProvider.Kind.ADB_STAGED -> "Staged over adb"
|
||||
CertProvider.Kind.SELF_SIGNED -> "Self-signed"
|
||||
val headline = when (identity.kind) {
|
||||
CertProvider.Kind.IMPORTED -> R.string.cert_kind_imported
|
||||
CertProvider.Kind.DOWNLOADED -> R.string.cert_kind_downloaded
|
||||
CertProvider.Kind.ADB_STAGED -> R.string.cert_kind_adb
|
||||
CertProvider.Kind.SELF_SIGNED -> R.string.cert_kind_self
|
||||
}
|
||||
return CertSummary(
|
||||
identity.kind,
|
||||
"$prefix: $name",
|
||||
context.getString(headline, name),
|
||||
cert.subjectX500Principal.name,
|
||||
cert.issuerX500Principal.name,
|
||||
DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(cert.notAfter.time)),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -29,7 +31,7 @@ fun CopyableCommand(command: String) {
|
||||
shape = MaterialTheme.shapes.small,
|
||||
onClick = {
|
||||
clipboard.setText(AnnotatedString(command))
|
||||
Toast.makeText(context, "Command copied", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, context.getString(R.string.command_copied), Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
) {
|
||||
@@ -41,7 +43,7 @@ fun CopyableCommand(command: String) {
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
"Tap to copy",
|
||||
stringResource(R.string.command_tap_copy),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -100,31 +102,30 @@ internal fun DonateDialog(live: Boolean) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { DonatePrompt.answer(context, donated = false) },
|
||||
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
|
||||
title = { Text("Glad that drive worked") },
|
||||
title = { Text(stringResource(R.string.donate_title)) },
|
||||
text = {
|
||||
Text(
|
||||
"Gearslip is free, with no ads and no tracking. If it gets you on the road, your " +
|
||||
"support keeps it going.",
|
||||
stringResource(R.string.donate_body),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { give(GearslipLinks.LIBERAPAY) }, Modifier.fillMaxWidth()) {
|
||||
Text("Support on Liberapay")
|
||||
Text(stringResource(R.string.donate_liberapay))
|
||||
}
|
||||
OutlinedButton(onClick = { give(GearslipLinks.BUY_ME_A_COFFEE) }, Modifier.fillMaxWidth()) {
|
||||
Text("Buy me a coffee")
|
||||
Text(stringResource(R.string.donate_bmac))
|
||||
}
|
||||
if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) {
|
||||
OutlinedButton(onClick = { give(GearslipLinks.PAYMENT_LINK) }, Modifier.fillMaxWidth()) {
|
||||
Text("Give once by card")
|
||||
Text(stringResource(R.string.donate_card))
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = { monero = true }, Modifier.fillMaxWidth()) {
|
||||
Text("Monero")
|
||||
}
|
||||
TextButton(onClick = { DonatePrompt.answer(context, donated = false) }, Modifier.fillMaxWidth()) {
|
||||
Text("Not now")
|
||||
Text(stringResource(R.string.worked_not_now))
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -40,54 +43,48 @@ import androidx.compose.ui.unit.dp
|
||||
* that can, in one tap, in which case it is shown as a button instead.
|
||||
*/
|
||||
private class Step(
|
||||
val action: String,
|
||||
val detail: String? = null,
|
||||
@param:StringRes val action: Int,
|
||||
@param:StringRes val detail: Int? = null,
|
||||
val command: String? = null,
|
||||
val buttonLabel: String? = null,
|
||||
@param:StringRes val buttonLabel: Int? = null,
|
||||
val onClick: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
private val FIRST_DRIVE = listOf(
|
||||
Step("Turn off Android Auto.", "Disable or uninstall the Android Auto app, so the car talks to Gearslip instead."),
|
||||
Step("Load a certificate.", "In Settings, download one or import your own."),
|
||||
Step(R.string.help_aa_off, R.string.help_aa_off_detail),
|
||||
Step(R.string.help_cert, R.string.help_cert_detail),
|
||||
Step(
|
||||
"Allow the microphone.",
|
||||
"Android won't let an app pass music to the car without it. Gearslip listens only when " +
|
||||
"you tap the mic or press the talk button on your wheel.",
|
||||
R.string.help_mic,
|
||||
R.string.help_mic_detail,
|
||||
),
|
||||
)
|
||||
|
||||
private fun inTheCar(onRequestCallScreening: () -> Unit) = listOf(
|
||||
Step(
|
||||
"Plug in, or connect wirelessly.",
|
||||
"Gearslip opens by itself when the car connects. For wireless, pair the phone with the " +
|
||||
"car first, then tap Connect wirelessly on Home.",
|
||||
R.string.help_plug_in,
|
||||
R.string.help_plug_in_detail,
|
||||
),
|
||||
Step(
|
||||
"Pair over Bluetooth for calls.",
|
||||
"Calls play through the car once your phone and the car are paired. Gearslip offers to " +
|
||||
"pair the first time you plug in.",
|
||||
R.string.help_pair,
|
||||
R.string.help_pair_detail,
|
||||
),
|
||||
Step(
|
||||
"Answer calls from the car screen.",
|
||||
"Set Gearslip as your caller ID and spam app. Without it, calls still ring, but only " +
|
||||
"the phone can answer them.",
|
||||
buttonLabel = "Set up",
|
||||
R.string.help_answer,
|
||||
R.string.help_answer_detail,
|
||||
buttonLabel = R.string.guide_set_up,
|
||||
onClick = onRequestCallScreening,
|
||||
),
|
||||
)
|
||||
|
||||
private val USB_AUDIO = listOf(
|
||||
Step(
|
||||
"Stop the audio prompt each drive.",
|
||||
"Connect the phone to a computer with USB debugging on, and run this once. Skip it and " +
|
||||
"you tap Start each time you set off.",
|
||||
R.string.help_prompt,
|
||||
R.string.help_prompt_detail,
|
||||
command = "adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow",
|
||||
),
|
||||
Step(
|
||||
"Keep notifications readable.",
|
||||
"Android hides what notifications say while it shares audio. Run this too, and Gearslip " +
|
||||
"lifts that only while music goes to the car.",
|
||||
R.string.help_readable,
|
||||
R.string.help_readable_detail,
|
||||
command = "adb shell pm grant ${BuildConfig.APPLICATION_ID} android.permission.WRITE_SECURE_SETTINGS",
|
||||
),
|
||||
)
|
||||
@@ -104,10 +101,10 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("How to connect") },
|
||||
title = { Text(stringResource(R.string.help_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -122,21 +119,20 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Spacer(Modifier.height(0.dp))
|
||||
SettingsSection("Which cars work") {
|
||||
SettingsSection(stringResource(R.string.help_which_cars)) {
|
||||
Text(
|
||||
"Most cars work. Starting around 2020, some cars added newer security " +
|
||||
"standards that reject Gearslip's certificate, and Gearslip can't run in those cars.",
|
||||
stringResource(R.string.help_which_cars_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
|
||||
)
|
||||
FilledTonalButton(
|
||||
onClick = { GearslipLinks.open(context, GearslipLinks.CARS) },
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) { Text("See the full list") }
|
||||
) { Text(stringResource(R.string.help_full_list)) }
|
||||
}
|
||||
Steps("Before your first drive", FIRST_DRIVE)
|
||||
Steps("In the car", car)
|
||||
if (usb) Steps("USB audio", USB_AUDIO)
|
||||
Steps(stringResource(R.string.help_first_drive), FIRST_DRIVE)
|
||||
Steps(stringResource(R.string.help_in_car), car)
|
||||
if (usb) Steps(stringResource(R.string.help_usb_audio), USB_AUDIO)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
@@ -160,14 +156,14 @@ private fun Steps(title: String, steps: List<Step>) {
|
||||
modifier = Modifier.width(16.dp),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(step.action, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(step.action), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
|
||||
step.detail?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(it), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
step.command?.let { CopyableCommand(it) }
|
||||
if (step.onClick != null) {
|
||||
FilledTonalButton(onClick = step.onClick, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text(step.buttonLabel ?: "Open")
|
||||
Text(stringResource(step.buttonLabel ?: R.string.guide_open))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.DarkStyle
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
@@ -39,30 +42,37 @@ 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"),
|
||||
MORE("More"),
|
||||
enum class CarGroup(@param:StringRes val title: Int) {
|
||||
SCREEN(R.string.car_group_screen),
|
||||
NAV_BAR(R.string.car_group_nav_bar),
|
||||
SOUND(R.string.car_group_sound),
|
||||
WARNINGS(R.string.car_group_warnings),
|
||||
MORE(R.string.car_group_more),
|
||||
}
|
||||
|
||||
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 darkChoices = listOf(
|
||||
DarkStyle.BLACK to "Black",
|
||||
DarkStyle.BLACK_COLOUR to "Black and colour",
|
||||
DarkStyle.DEEP_COLOUR to "Deep colour",
|
||||
DarkStyle.GREY to "Grey",
|
||||
@Composable
|
||||
private fun sizeChoices() = listOf(1f to stringResource(R.string.choice_size_normal), 1.25f to stringResource(R.string.choice_size_large), 1.5f to stringResource(R.string.choice_size_xlarge))
|
||||
@Composable
|
||||
private fun themeChoices() = listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark))
|
||||
@Composable
|
||||
private fun darkChoices() = listOf(
|
||||
DarkStyle.BLACK to stringResource(R.string.choice_dark_black),
|
||||
DarkStyle.BLACK_COLOUR to stringResource(R.string.choice_dark_black_colour),
|
||||
DarkStyle.DEEP_COLOUR to stringResource(R.string.choice_dark_deep_colour),
|
||||
DarkStyle.GREY to stringResource(R.string.choice_dark_grey),
|
||||
)
|
||||
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",
|
||||
@Composable
|
||||
private fun nightChoices() = listOf(NightMode.AUTO to stringResource(R.string.choice_auto), NightMode.DAY to stringResource(R.string.choice_night_day), NightMode.NIGHT to stringResource(R.string.choice_night_night))
|
||||
@Composable
|
||||
private fun unitChoices() = listOf(Units.AUTO to stringResource(R.string.choice_auto), Units.IMPERIAL to stringResource(R.string.choice_units_imperial), Units.METRIC to stringResource(R.string.choice_units_metric))
|
||||
@Composable
|
||||
private fun navBarChoices() = listOf(
|
||||
NavBarMode.SHOW to stringResource(R.string.choice_nav_show),
|
||||
NavBarMode.HIDE_NAVIGATING to stringResource(R.string.choice_nav_hide_navigating),
|
||||
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
|
||||
)
|
||||
private fun openChoices() = listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label }
|
||||
@Composable
|
||||
private fun openChoices() = listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label }
|
||||
|
||||
/**
|
||||
* The car's settings on the phone's home screen: one card per group with a summary of where it
|
||||
@@ -82,20 +92,32 @@ internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Mo
|
||||
val theme by CarSettings.appTheme.collectAsState()
|
||||
val experimental by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||
|
||||
val opens = openChoices()
|
||||
val sizes = sizeChoices()
|
||||
val themes = themeChoices()
|
||||
val navBars = navBarChoices()
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
fun str(id: Int) = context.getString(id)
|
||||
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.SCREEN -> listOf(opens.label(open), sizes.label(scale), themes.label(theme))
|
||||
CarGroup.NAV_BAR -> listOf(navBars.label(navBar), str(if (playerLeft) R.string.summary_player_left else R.string.summary_player_right))
|
||||
CarGroup.SOUND -> listOf(
|
||||
str(if (pipeAudio) R.string.summary_usb_audio else R.string.summary_bluetooth_audio),
|
||||
str(if (speakDirections) R.string.summary_directions_on else R.string.summary_directions_off),
|
||||
)
|
||||
CarGroup.WARNINGS -> listOf(
|
||||
"Bluetooth ${if (bluetoothWarning && !pipeAudio) "on" else "off"}",
|
||||
"Location ${if (locationWarning) "on" else "off"}",
|
||||
str(if (bluetoothWarning && !pipeAudio) R.string.summary_bluetooth_on else R.string.summary_bluetooth_off),
|
||||
str(if (locationWarning) R.string.summary_location_on else R.string.summary_location_off),
|
||||
)
|
||||
CarGroup.MORE -> listOf(
|
||||
str(R.string.web_page),
|
||||
str(if (experimental) R.string.summary_experimental_shown else R.string.summary_experimental_hidden),
|
||||
)
|
||||
CarGroup.MORE -> listOf("Web page", "Experimental features ${if (experimental) "shown" else "hidden"}")
|
||||
}.filter { it.isNotEmpty() }.joinToString(" · ")
|
||||
|
||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Car",
|
||||
stringResource(R.string.home_car),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
@@ -105,7 +127,7 @@ internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Mo
|
||||
CarGroup.entries.forEachIndexed { i, group ->
|
||||
if (i > 0) SettingsDivider()
|
||||
SettingsRow(
|
||||
group.title,
|
||||
stringResource(group.title),
|
||||
Modifier.clickable { onOpen(group) },
|
||||
subtitle = summary(group),
|
||||
trailing = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) },
|
||||
@@ -123,10 +145,10 @@ internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) {
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
androidx.compose.material3.TopAppBar(
|
||||
title = { Text(group.title) },
|
||||
title = { Text(stringResource(group.title)) },
|
||||
navigationIcon = {
|
||||
androidx.compose.material3.IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -149,7 +171,7 @@ internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) {
|
||||
else -> Unit
|
||||
}
|
||||
Text(
|
||||
"These are the same settings as Settings in the car. A change here shows there too.",
|
||||
stringResource(R.string.car_group_same),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
@@ -183,78 +205,78 @@ private fun GroupRows(group: CarGroup) {
|
||||
|
||||
when (group) {
|
||||
CarGroup.SCREEN -> {
|
||||
Choice("Open on connect", openChoices(), open, CarSettings::setOpenOnConnect)
|
||||
Choice(stringResource(R.string.car_open_on_connect), openChoices(), open, CarSettings::setOpenOnConnect)
|
||||
SettingsDivider()
|
||||
Choice("Size", sizeChoices, scale, CarSettings::setScale)
|
||||
Choice(stringResource(R.string.car_size), sizeChoices(), scale, CarSettings::setScale)
|
||||
SettingsDivider()
|
||||
Choice("App theme", themeChoices, theme, CarSettings::setAppTheme)
|
||||
Choice(stringResource(R.string.car_app_theme), themeChoices(), theme, CarSettings::setAppTheme)
|
||||
SettingsDivider()
|
||||
if (theme != AppTheme.LIGHT) {
|
||||
Choice("Dark mode", darkChoices, darkStyle, CarSettings::setDarkStyle)
|
||||
Choice(stringResource(R.string.car_dark_mode), darkChoices(), darkStyle, CarSettings::setDarkStyle)
|
||||
SettingsDivider()
|
||||
}
|
||||
Choice("Buttons", GsThemes.builtIn.map { it.id to it.name }, uiTheme, CarSettings::setUiTheme)
|
||||
Choice(stringResource(R.string.car_buttons), GsThemes.builtIn.map { it.id to it.name }, uiTheme, CarSettings::setUiTheme)
|
||||
SettingsDivider()
|
||||
Choice("Map day/night", nightChoices, night, CarSettings::setNightMode)
|
||||
Choice(stringResource(R.string.car_map_day_night), nightChoices(), night, CarSettings::setNightMode)
|
||||
SettingsDivider()
|
||||
Choice("Units", unitChoices, units, CarSettings::setUnits)
|
||||
Choice(stringResource(R.string.car_units), unitChoices(), units, CarSettings::setUnits)
|
||||
}
|
||||
CarGroup.NAV_BAR -> {
|
||||
Choice("Nav bar", navBarChoices, navBar, CarSettings::setNavBarMode)
|
||||
Choice(stringResource(R.string.car_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)
|
||||
Choice(stringResource(R.string.car_player_nav_bar), listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)), playerTitle, CarSettings::setPlayerTitle)
|
||||
SettingsDivider()
|
||||
Choice("Player beside the map", listOf(false to "Right", true to "Left"), playerLeft, CarSettings::setPlayerLeft)
|
||||
Choice(stringResource(R.string.car_player_beside_map), listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)), playerLeft, CarSettings::setPlayerLeft)
|
||||
SettingsDivider()
|
||||
Toggle("Autoplay on connect", autoplay, CarSettings::setAutoplay)
|
||||
Toggle(stringResource(R.string.car_autoplay), autoplay, CarSettings::setAutoplay)
|
||||
SettingsDivider()
|
||||
Toggle(
|
||||
"More lyrics sources",
|
||||
stringResource(R.string.car_more_lyrics),
|
||||
moreLyrics,
|
||||
CarSettings::setMoreLyrics,
|
||||
note = "Also asks BetterLyrics and KuGou, which then get the song's name and artist. Off asks LRCLIB only.",
|
||||
note = stringResource(R.string.car_more_lyrics_note),
|
||||
)
|
||||
}
|
||||
CarGroup.SOUND -> {
|
||||
Toggle(
|
||||
"Spoken directions",
|
||||
stringResource(R.string.car_spoken_directions),
|
||||
speakDirections,
|
||||
CarSettings::setSpeakDirections,
|
||||
note = "Turn it off if your map app's own voice already plays in the car.",
|
||||
note = stringResource(R.string.car_spoken_directions_note),
|
||||
)
|
||||
SettingsDivider()
|
||||
Toggle(
|
||||
"Use the phone's speech recognition",
|
||||
stringResource(R.string.car_phone_speech),
|
||||
systemSpeech,
|
||||
CarSettings::setSystemSpeech,
|
||||
note = "On most phones that's Google's, which may send your voice to Google.",
|
||||
note = stringResource(R.string.car_phone_speech_note),
|
||||
)
|
||||
}
|
||||
CarGroup.WARNINGS -> {
|
||||
if (!pipeAudio) {
|
||||
Toggle("Warn when Bluetooth isn't connected", bluetoothWarning, CarSettings::setBluetoothWarning)
|
||||
Toggle(stringResource(R.string.car_warn_bluetooth), bluetoothWarning, CarSettings::setBluetoothWarning)
|
||||
SettingsDivider()
|
||||
}
|
||||
Toggle("Warn when location is off", locationWarning, CarSettings::setLocationWarning)
|
||||
Toggle(stringResource(R.string.car_warn_location), locationWarning, CarSettings::setLocationWarning)
|
||||
}
|
||||
CarGroup.MORE -> {
|
||||
WebPageRow()
|
||||
SettingsDivider()
|
||||
Toggle("Experimental features", experimental, CarSettings::setExperimentalFeaturesEnabled)
|
||||
Toggle(stringResource(R.string.car_experimental), experimental, CarSettings::setExperimentalFeaturesEnabled)
|
||||
if (experimental) {
|
||||
SettingsDivider()
|
||||
Toggle(
|
||||
"Vehicle data",
|
||||
stringResource(R.string.car_vehicle_data),
|
||||
carSensors,
|
||||
CarSettings::setCarSensorsEnabled,
|
||||
note = "Adds a Vehicle data tile: a live dump of the car's sensors. The field names are guesses until you've watched one change.",
|
||||
note = stringResource(R.string.car_vehicle_data_note),
|
||||
)
|
||||
SettingsDivider()
|
||||
Toggle(
|
||||
"Adaptive video quality",
|
||||
stringResource(R.string.car_adaptive_video),
|
||||
adaptiveVideo,
|
||||
CarSettings::setAdaptiveVideoEnabled,
|
||||
note = "Lowers the picture's quality when the car or its Wi-Fi can't keep up, and raises it again once it can. Applies on the next connection.",
|
||||
note = stringResource(R.string.car_adaptive_video_note),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -312,14 +334,14 @@ private fun WebPageRow() {
|
||||
url = it
|
||||
app.seb3thehacker.gearslip.AppSettings.setStartupUrl(context, it)
|
||||
},
|
||||
label = { Text("Web page") },
|
||||
label = { Text(stringResource(R.string.web_page)) },
|
||||
placeholder = { Text("DuckDuckGo") },
|
||||
singleLine = true,
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
"Shown by the Browser app in the car. Empty opens DuckDuckGo. Applies on the next connection.",
|
||||
stringResource(R.string.web_page_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
@@ -332,20 +354,20 @@ private fun WebPageRow() {
|
||||
internal fun CarAudioSection() {
|
||||
val usb by CarSettings.pipeAudio.collectAsState()
|
||||
SettingsSection(
|
||||
"Car audio",
|
||||
footer = if (usb) "Run the command in Help once, and USB stops asking." else "Calls go over Bluetooth either way.",
|
||||
stringResource(R.string.car_audio),
|
||||
footer = stringResource(if (usb) R.string.car_audio_usb_footer else R.string.car_audio_bt_footer),
|
||||
) {
|
||||
SettingsRow(
|
||||
"Bluetooth (Recommended)",
|
||||
stringResource(R.string.car_audio_bt),
|
||||
Modifier.selectable(!usb, role = androidx.compose.ui.semantics.Role.RadioButton) { CarSettings.setPipeAudio(false) },
|
||||
subtitle = "No prompt each drive, and your map app's voice reaches the car",
|
||||
subtitle = stringResource(R.string.car_audio_bt_sub),
|
||||
trailing = { androidx.compose.material3.RadioButton(selected = !usb, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"USB",
|
||||
stringResource(R.string.guide_audio_usb),
|
||||
Modifier.selectable(usb, role = androidx.compose.ui.semantics.Role.RadioButton) { CarSettings.setPipeAudio(true) },
|
||||
subtitle = "Clearer sound with no delay. Each drive, Android asks you to share your screen, which lets Gearslip send the sound",
|
||||
subtitle = stringResource(R.string.car_audio_usb_sub),
|
||||
trailing = { androidx.compose.material3.RadioButton(selected = usb, onClick = null) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.speech.SpeechModels
|
||||
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
@@ -115,15 +117,15 @@ fun HomeScreen(
|
||||
// Sits at the bottom, where a thumb reaches it.
|
||||
Column(Modifier.padding(vertical = 16.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
HomeShortcut(Icons.Filled.Info, "Help", onOpenHelp, Modifier.weight(1f))
|
||||
HomeShortcut(Icons.Filled.Info, stringResource(R.string.home_help), onOpenHelp, Modifier.weight(1f))
|
||||
// The preview draws the car screen on the phone, so it waits until no car is using it.
|
||||
if (!live) HomeShortcut(Icons.Filled.PlayArrow, "Preview", onOpenCarPreview, Modifier.weight(1f))
|
||||
HomeShortcut(Icons.AutoMirrored.Filled.List, "Logs", onOpenLogs, Modifier.weight(1f))
|
||||
if (!live) HomeShortcut(Icons.Filled.PlayArrow, stringResource(R.string.home_preview), onOpenCarPreview, Modifier.weight(1f))
|
||||
HomeShortcut(Icons.AutoMirrored.Filled.List, stringResource(R.string.home_logs), onOpenLogs, Modifier.weight(1f))
|
||||
}
|
||||
if (live) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text("Disconnect")
|
||||
Text(stringResource(R.string.home_disconnect))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,12 +158,12 @@ private fun HomeHeader(onOpenSettings: () -> Unit) {
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onOpenSettings, modifier = Modifier.size(56.dp)) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(32.dp))
|
||||
Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings_title), modifier = Modifier.size(32.dp))
|
||||
}
|
||||
}
|
||||
val version = "v${LocalContext.current.appVersionName()}"
|
||||
Text(
|
||||
if (debug) "$version · debug build" else version,
|
||||
if (debug) stringResource(R.string.home_debug_build, version) else version,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (debug) content else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -206,10 +208,9 @@ private fun BluetoothMissingCard() {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Surface(color = container, contentColor = content, shape = MaterialTheme.shapes.extraLarge) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Bluetooth isn't connected", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.home_bt_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Music plays over Bluetooth, but your phone isn't connected to the car. Connect it " +
|
||||
"here or from the car's Bluetooth menu.",
|
||||
stringResource(R.string.home_bt_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(
|
||||
@@ -220,7 +221,7 @@ private fun BluetoothMissingCard() {
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
) { Text("Bluetooth settings") }
|
||||
) { Text(stringResource(R.string.bluetooth_settings)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,7 +234,7 @@ private fun VoiceDownloadCard() {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SettingsCard {
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Downloading voice, $percent%", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(stringResource(R.string.home_voice_downloading, percent), style = MaterialTheme.typography.bodyLarge)
|
||||
LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
@@ -108,9 +111,9 @@ private fun StorageScreen(onBack: () -> Unit) {
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Gearslip storage") },
|
||||
title = { Text(stringResource(R.string.storage_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") }
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -121,23 +124,23 @@ private fun StorageScreen(onBack: () -> Unit) {
|
||||
) {
|
||||
val u = usage
|
||||
Text(
|
||||
if (u == null) "Measuring…" else "Gearslip's data takes ${size(u.total)} of the phone's storage.",
|
||||
if (u == null) stringResource(R.string.storage_measuring) else stringResource(R.string.storage_total, size(u.total)),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
SettingsCard {
|
||||
StorageRow(
|
||||
"Lyrics",
|
||||
u?.let { "${it.lyricsSongs} songs · ${size(it.lyrics)}. Cleared lyrics download again when you open them." },
|
||||
stringResource(R.string.storage_lyrics),
|
||||
u?.let { pluralStringResource(R.plurals.storage_lyrics_detail, it.lyricsSongs, it.lyricsSongs, size(it.lyrics)) },
|
||||
enabled = (u?.lyrics ?: 0) > 0,
|
||||
action = "Clear",
|
||||
action = stringResource(R.string.storage_clear),
|
||||
) { clear { Lyrics.clearCache(context) } }
|
||||
SettingsDivider()
|
||||
StorageRow(
|
||||
"Voice models",
|
||||
u?.let { "${size(it.voice)}. Voice commands need them; download them again in Settings." },
|
||||
stringResource(R.string.storage_voice),
|
||||
u?.let { stringResource(R.string.storage_voice_detail, size(it.voice)) },
|
||||
enabled = (u?.voice ?: 0) > 0,
|
||||
action = "Remove",
|
||||
action = stringResource(R.string.storage_remove),
|
||||
) {
|
||||
clear {
|
||||
voiceDir(context).deleteRecursively()
|
||||
@@ -146,17 +149,17 @@ private fun StorageScreen(onBack: () -> Unit) {
|
||||
}
|
||||
SettingsDivider()
|
||||
StorageRow(
|
||||
"Old logs",
|
||||
u?.let { "${size(it.logs)}. Logs from earlier launches; this launch's log stays." },
|
||||
stringResource(R.string.storage_logs),
|
||||
u?.let { stringResource(R.string.storage_logs_detail, size(it.logs)) },
|
||||
enabled = (u?.logs ?: 0) > 0,
|
||||
action = "Clear",
|
||||
action = stringResource(R.string.storage_clear),
|
||||
) { clear { oldLogs(context).forEach { it.deleteRecursively() } } }
|
||||
SettingsDivider()
|
||||
StorageRow(
|
||||
"Other temporary files",
|
||||
u?.let { "${size(it.other)}. Downloaded updates and other files Gearslip can make again." },
|
||||
stringResource(R.string.storage_other),
|
||||
u?.let { stringResource(R.string.storage_other_detail, size(it.other)) },
|
||||
enabled = (u?.other ?: 0) > 0,
|
||||
action = "Clear",
|
||||
action = stringResource(R.string.storage_clear),
|
||||
) { clear { otherCache(context).forEach { it.deleteRecursively() } } }
|
||||
}
|
||||
DeleteEverything()
|
||||
@@ -168,7 +171,7 @@ private fun StorageScreen(onBack: () -> Unit) {
|
||||
private fun StorageRow(title: String, detail: String?, enabled: Boolean, action: String, onClick: () -> Unit) {
|
||||
SettingsRow(
|
||||
title,
|
||||
subtitle = detail ?: "Measuring…",
|
||||
subtitle = detail ?: stringResource(R.string.storage_measuring),
|
||||
trailing = { TextButton(onClick = onClick, enabled = enabled) { Text(action) } },
|
||||
)
|
||||
}
|
||||
@@ -180,18 +183,18 @@ private fun DeleteEverything() {
|
||||
var armed by remember { mutableStateOf(false) }
|
||||
SettingsCard {
|
||||
SettingsRow(
|
||||
"Delete all Gearslip data",
|
||||
stringResource(R.string.storage_delete_all),
|
||||
subtitle = if (armed) {
|
||||
"Tap Delete again to erase settings, certificates, voice models and logs. Gearslip closes, and starts from setup next time."
|
||||
stringResource(R.string.storage_delete_armed)
|
||||
} else {
|
||||
"Settings, certificates, voice models, lyrics and logs."
|
||||
stringResource(R.string.storage_delete_sub)
|
||||
},
|
||||
titleColor = MaterialTheme.colorScheme.error,
|
||||
trailing = {
|
||||
TextButton(onClick = {
|
||||
if (armed) context.getSystemService(ActivityManager::class.java)?.clearApplicationUserData()
|
||||
else armed = true
|
||||
}) { Text(if (armed) "Delete" else "Delete…", color = MaterialTheme.colorScheme.error) }
|
||||
}) { Text(stringResource(if (armed) R.string.storage_delete else R.string.storage_delete_ellipsis), color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
@@ -49,7 +51,7 @@ fun PhoneTypingBar(modifier: Modifier = Modifier) {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.fillMaxWidth().imePadding()) {
|
||||
Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = PhoneTyping::dismiss) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Close the car keyboard")
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.typing_close))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
@@ -57,14 +59,14 @@ fun PhoneTypingBar(modifier: Modifier = Modifier) {
|
||||
value = it
|
||||
PhoneTyping.type(it.text)
|
||||
},
|
||||
label = { Text("Type for the car screen") },
|
||||
label = { Text(stringResource(R.string.typing_label)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { PhoneTyping.submit() }),
|
||||
modifier = Modifier.weight(1f).focusRequester(focus),
|
||||
)
|
||||
IconButton(onClick = PhoneTyping::submit) {
|
||||
Icon(Icons.Filled.Search, contentDescription = "Search on the car screen")
|
||||
Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.typing_search))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Drawable
|
||||
@@ -43,22 +46,22 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
|
||||
/** A free app that runs on Gearslip's car screen, and where to get it. */
|
||||
private class RecommendedApp(val name: String, val packageName: String, val url: String, val about: String)
|
||||
private class RecommendedApp(val name: String, val packageName: String, val url: String, @param:StringRes val about: Int)
|
||||
|
||||
private val recommended = listOf(
|
||||
"Maps" to listOf(
|
||||
RecommendedApp("Organic Maps", "app.organicmaps", "https://github.com/organicmaps/organicmaps", "Offline maps and routing."),
|
||||
RecommendedApp("Vela Maps", "app.vela", "https://github.com/PimpinPumpkin/Vela", "Turn-by-turn navigation without Google."),
|
||||
R.string.recommended_maps to listOf(
|
||||
RecommendedApp("Organic Maps", "app.organicmaps", "https://github.com/organicmaps/organicmaps", R.string.recommended_about_organic),
|
||||
RecommendedApp("Vela Maps", "app.vela", "https://github.com/PimpinPumpkin/Vela", R.string.recommended_about_vela),
|
||||
),
|
||||
"Music" to listOf(
|
||||
RecommendedApp("ViVi Music", "com.vivi.vivimusic", "https://github.com/vivizzz007/vivi-music", "A YouTube Music player."),
|
||||
RecommendedApp("Metrolist", "com.metrolist.music", "https://github.com/MetrolistGroup/Metrolist", "A YouTube Music player."),
|
||||
R.string.recommended_music to listOf(
|
||||
RecommendedApp("ViVi Music", "com.vivi.vivimusic", "https://github.com/vivizzz007/vivi-music", R.string.recommended_about_ytm),
|
||||
RecommendedApp("Metrolist", "com.metrolist.music", "https://github.com/MetrolistGroup/Metrolist", R.string.recommended_about_ytm),
|
||||
),
|
||||
"Podcasts" to listOf(
|
||||
RecommendedApp("AntennaPod", "de.danoeh.antennapod", "https://github.com/AntennaPod/AntennaPod", "Podcasts, chapters and all."),
|
||||
R.string.recommended_podcasts to listOf(
|
||||
RecommendedApp("AntennaPod", "de.danoeh.antennapod", "https://github.com/AntennaPod/AntennaPod", R.string.recommended_about_antennapod),
|
||||
),
|
||||
"Other cool apps" to listOf(
|
||||
RecommendedApp("RF Sentinel", "com.rfsentinel.app", "https://github.com/CIS-C0/RFSentinel", "Spots trackers and cameras nearby."),
|
||||
R.string.recommended_other to listOf(
|
||||
RecommendedApp("RF Sentinel", "com.rfsentinel.app", "https://github.com/CIS-C0/RFSentinel", R.string.recommended_about_rfsentinel),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -70,10 +73,10 @@ fun RecommendedAppsScreen(onBack: () -> Unit) {
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Recommended apps") },
|
||||
title = { Text(stringResource(R.string.recommended_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -87,7 +90,7 @@ fun RecommendedAppsScreen(onBack: () -> Unit) {
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
RECOMMENDED_INTRO,
|
||||
stringResource(R.string.recommended_intro),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -101,9 +104,6 @@ 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."
|
||||
|
||||
/** The same green as the launcher's "works" check. */
|
||||
private val INSTALLED_GREEN = Color(0xFF43A047)
|
||||
|
||||
@@ -115,7 +115,7 @@ internal fun RecommendedAppsList(modifier: Modifier = Modifier) {
|
||||
for ((section, apps) in recommended) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
section,
|
||||
stringResource(section),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
@@ -145,12 +145,12 @@ private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) {
|
||||
// Colours set outright: the setup guide draws this on its own background, with no
|
||||
// Surface to hand down a content colour, and unset text came out dark on dark.
|
||||
Text(app.name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(app.about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(app.about), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (icon != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = INSTALLED_GREEN, modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
"Installed",
|
||||
stringResource(R.string.recommended_installed),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = INSTALLED_GREEN,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -107,10 +109,10 @@ fun SettingsScreen(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
title = { Text(stringResource(R.string.settings_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -128,25 +130,25 @@ fun SettingsScreen(
|
||||
SettingsSection {
|
||||
val context = LocalContext.current
|
||||
SettingsRow(
|
||||
"What's new",
|
||||
stringResource(R.string.settings_whats_new),
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.RELEASE_NOTES) },
|
||||
subtitle = "Read the ${BuildConfig.VERSION_NAME} release notes on GitHub",
|
||||
subtitle = stringResource(R.string.settings_whats_new_sub, BuildConfig.VERSION_NAME),
|
||||
icon = Icons.Filled.Star,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
}
|
||||
SettingsSection(
|
||||
"Support Gearslip",
|
||||
footer = "Gearslip is free, with no ads and no tracking. Your support keeps it going.",
|
||||
stringResource(R.string.settings_support),
|
||||
footer = stringResource(R.string.settings_support_footer),
|
||||
) { DonateRows() }
|
||||
if (ready(2)) ConnectionSection()
|
||||
if (SHOW_RECOMMENDED_APPS && ready(6)) AppsSection(onOpenRecommendedApps)
|
||||
if (ready(7)) SettingsSection(
|
||||
"Usage notes",
|
||||
stringResource(R.string.settings_usage_notes),
|
||||
Modifier.onGloballyPositioned { usageNotesY = it.positionInParent().y.toInt() },
|
||||
header = USAGE_STATS_INTRO,
|
||||
header = usageStatsIntro(LocalContext.current),
|
||||
) { UsageStatsRows() }
|
||||
if (ready(8)) SettingsSection("Updates") { UpdateRow() }
|
||||
if (ready(8)) SettingsSection(stringResource(R.string.settings_updates)) { UpdateRow() }
|
||||
if (ready(9)) CertificateSection()
|
||||
if (ready(10)) AboutSection(onReplayTutorial)
|
||||
if (BuildConfig.DEBUG && ready(11)) DebugSection()
|
||||
@@ -168,11 +170,11 @@ private fun Arrow() = Icon(
|
||||
/** The setup guide replay, and where Gearslip lives outside the app. */
|
||||
@Composable
|
||||
private fun AboutSection(onReplayTutorial: () -> Unit) {
|
||||
SettingsSection("About") {
|
||||
SettingsSection(stringResource(R.string.settings_about)) {
|
||||
SettingsRow(
|
||||
"Replay the setup guide",
|
||||
stringResource(R.string.settings_replay),
|
||||
Modifier.clickable(onClick = onReplayTutorial),
|
||||
subtitle = "Go through the first-run steps again",
|
||||
subtitle = stringResource(R.string.settings_replay_sub),
|
||||
icon = Icons.Filled.Refresh,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
@@ -186,14 +188,14 @@ private fun AboutSection(onReplayTutorial: () -> Unit) {
|
||||
private fun ConnectionSection() {
|
||||
val context = LocalContext.current
|
||||
var enabled by remember { mutableStateOf(AppSettings.gearslipEnabled(context)) }
|
||||
SettingsSection("Connection") {
|
||||
SettingsSection(stringResource(R.string.settings_connection)) {
|
||||
SettingsRow(
|
||||
"Use Gearslip in the car",
|
||||
stringResource(R.string.settings_use_in_car),
|
||||
Modifier.toggleable(enabled, role = Role.Switch) {
|
||||
enabled = 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",
|
||||
subtitle = stringResource(if (enabled) R.string.settings_use_in_car_on else R.string.settings_use_in_car_off),
|
||||
icon = Icons.Filled.Settings,
|
||||
trailing = { Switch(checked = enabled, onCheckedChange = null) },
|
||||
)
|
||||
@@ -224,9 +226,9 @@ private fun DebugSection() {
|
||||
|
||||
@Composable
|
||||
private fun AppsSection(onOpenRecommendedApps: () -> Unit) {
|
||||
SettingsSection("Apps") {
|
||||
SettingsSection(stringResource(R.string.settings_apps)) {
|
||||
SettingsRow(
|
||||
"Recommended apps",
|
||||
stringResource(R.string.recommended_title),
|
||||
Modifier.clickable(onClick = onOpenRecommendedApps),
|
||||
icon = Icons.Filled.Star,
|
||||
trailing = { Arrow() },
|
||||
@@ -253,21 +255,21 @@ private fun CertificateSection() {
|
||||
if (uri != null) pendingUri = uri
|
||||
}
|
||||
|
||||
SettingsSection("Certificate", footer = notice ?: "Changes take effect on the next connection.") {
|
||||
SettingsSection(stringResource(R.string.settings_certificate), footer = notice ?: stringResource(R.string.cert_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…",
|
||||
s?.headline ?: stringResource(R.string.cert_checking),
|
||||
if (hasDetails) Modifier.clickable { detailsOpen = !detailsOpen } else Modifier,
|
||||
subtitle = s?.validUntil?.let { "Valid until $it" },
|
||||
subtitle = s?.validUntil?.let { stringResource(R.string.cert_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",
|
||||
contentDescription = stringResource(if (detailsOpen) R.string.cert_hide_details else R.string.cert_show_details),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -278,39 +280,39 @@ private fun CertificateSection() {
|
||||
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) }
|
||||
s?.subject?.let { Detail(stringResource(R.string.cert_subject), it) }
|
||||
s?.issuer?.let { Detail(stringResource(R.string.cert_issuer), it) }
|
||||
}
|
||||
}
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
if (downloading) "Downloading…" else "Download from aasdk",
|
||||
stringResource(if (downloading) R.string.cert_downloading else R.string.cert_download),
|
||||
Modifier.clickable(enabled = !downloading) {
|
||||
downloading = true
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) { runCatching { CertProvider.downloadAndImport(context) } }
|
||||
downloading = false
|
||||
result.onSuccess {
|
||||
notice = "Certificate downloaded."
|
||||
notice = context.getString(R.string.cert_downloaded)
|
||||
refresh++
|
||||
}.onFailure {
|
||||
notice = "Download failed: ${it.message}"
|
||||
notice = context.getString(R.string.cert_download_failed, it.message.toString())
|
||||
}
|
||||
}
|
||||
},
|
||||
subtitle = "The certificate the open-source aasdk project publishes on GitHub. Gearslip doesn't include one",
|
||||
subtitle = stringResource(R.string.cert_download_sub),
|
||||
icon = Icons.Filled.Refresh,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Import my own file",
|
||||
stringResource(R.string.cert_import),
|
||||
Modifier.clickable { picker.launch(arrayOf("*/*")) },
|
||||
subtitle = "A .p12 file with its private key, from wherever you choose",
|
||||
subtitle = stringResource(R.string.cert_import_sub),
|
||||
icon = Icons.Filled.Add,
|
||||
)
|
||||
val removable = when (s?.kind) {
|
||||
CertProvider.Kind.IMPORTED -> "Remove imported certificate"
|
||||
CertProvider.Kind.DOWNLOADED -> "Remove downloaded certificate"
|
||||
CertProvider.Kind.IMPORTED -> stringResource(R.string.cert_remove_imported)
|
||||
CertProvider.Kind.DOWNLOADED -> stringResource(R.string.cert_remove_downloaded)
|
||||
else -> null
|
||||
}
|
||||
if (removable != null) {
|
||||
@@ -319,7 +321,7 @@ private fun CertificateSection() {
|
||||
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."
|
||||
notice = context.getString(if (s?.kind == CertProvider.Kind.IMPORTED) R.string.cert_removed_imported else R.string.cert_removed_downloaded)
|
||||
refresh++
|
||||
},
|
||||
icon = Icons.Filled.Delete,
|
||||
@@ -339,10 +341,10 @@ private fun CertificateSection() {
|
||||
}
|
||||
result.onSuccess {
|
||||
pendingUri = null
|
||||
notice = "Certificate imported."
|
||||
notice = context.getString(R.string.cert_imported)
|
||||
refresh++
|
||||
}.onFailure {
|
||||
reportError("Could not import: wrong password, or not a PKCS#12 file with a private key.")
|
||||
reportError(context.getString(R.string.cert_import_failed))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -365,20 +367,20 @@ private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) -
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Certificate password") },
|
||||
title = { Text(stringResource(R.string.cert_password_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it; error = null },
|
||||
singleLine = true,
|
||||
label = { Text("Password") },
|
||||
label = { Text(stringResource(R.string.cert_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
isError = error != null,
|
||||
supportingText = { Text(error ?: "Leave empty if the file has none.") },
|
||||
supportingText = { Text(error ?: stringResource(R.string.cert_password_hint)) },
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text(stringResource(R.string.cert_import_button)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.speech.SpeechModels
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -160,8 +162,8 @@ private fun hasNotificationAccess(context: Context): Boolean {
|
||||
* who set up before the notes existed see once. Leaving it without turning them on is a no.
|
||||
*/
|
||||
private fun usageNotesPage(context: Context) = GuidePage(
|
||||
title = "Help other drivers.",
|
||||
explanation = USAGE_STATS_INTRO,
|
||||
title = context.getString(R.string.guide_usage_title),
|
||||
explanation = usageStatsIntro(context),
|
||||
icon = { HeartIcon(it) },
|
||||
content = {
|
||||
UsageStatsChoices()
|
||||
@@ -175,9 +177,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
// --- Safety: always first, and never skipped by an already-granted check ---------
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Drive safely.",
|
||||
explanation = "Gearslip is unofficial. No carmaker made or checked it, and it can fail " +
|
||||
"without warning. Set it up before you go, then keep your eyes on the road.",
|
||||
title = context.getString(R.string.guide_safety_title),
|
||||
explanation = context.getString(R.string.guide_safety_body),
|
||||
icon = glyph(Icons.Filled.Warning),
|
||||
),
|
||||
)
|
||||
@@ -187,9 +188,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Microphone.",
|
||||
explanation = "Gearslip uses it to send your music to the car and to hear you when you " +
|
||||
"ask for something or reply to a message. What you say stays on the phone.",
|
||||
title = context.getString(R.string.guide_mic_title),
|
||||
explanation = context.getString(R.string.guide_mic_body),
|
||||
icon = { MicIcon(it) },
|
||||
permissions = arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||
),
|
||||
@@ -198,9 +198,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!granted(context, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Location.",
|
||||
explanation = "The car's map and nearby searches need it. Without it, the dashboard " +
|
||||
"shows no map.",
|
||||
title = context.getString(R.string.guide_location_title),
|
||||
explanation = context.getString(R.string.guide_location_body),
|
||||
icon = glyph(Icons.Filled.LocationOn),
|
||||
permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
|
||||
),
|
||||
@@ -209,8 +208,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!granted(context, arrayOf(Manifest.permission.READ_CALENDAR))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Calendar.",
|
||||
explanation = "The car screen shows your next event. Gearslip only reads your calendar.",
|
||||
title = context.getString(R.string.guide_calendar_title),
|
||||
explanation = context.getString(R.string.guide_calendar_body),
|
||||
icon = glyph(Icons.Filled.DateRange),
|
||||
permissions = arrayOf(Manifest.permission.READ_CALENDAR),
|
||||
),
|
||||
@@ -226,9 +225,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!granted(context, callPermissions)) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Calls and contacts.",
|
||||
explanation = "The car shows who's calling by name, and its Phone app can dial a " +
|
||||
"number or call one of your contacts. Gearslip only reads them.",
|
||||
title = context.getString(R.string.guide_calls_title),
|
||||
explanation = context.getString(R.string.guide_calls_body),
|
||||
icon = glyph(Icons.Filled.Phone),
|
||||
permissions = callPermissions,
|
||||
),
|
||||
@@ -237,9 +235,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!granted(context, arrayOf(Manifest.permission.BLUETOOTH_CONNECT))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Bluetooth.",
|
||||
explanation = "The first time you plug in, Gearslip offers to pair your phone with the " +
|
||||
"car, so calls play through its speakers.",
|
||||
title = context.getString(R.string.guide_bluetooth_title),
|
||||
explanation = context.getString(R.string.guide_bluetooth_body),
|
||||
icon = glyph(Icons.Filled.Call),
|
||||
permissions = arrayOf(Manifest.permission.BLUETOOTH_CONNECT),
|
||||
),
|
||||
@@ -249,9 +246,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!granted(context, arrayOf(Manifest.permission.POST_NOTIFICATIONS))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Notifications.",
|
||||
explanation = "While Gearslip is connected, it shows a notification with a button " +
|
||||
"to stop. Without this, Android hides it.",
|
||||
title = context.getString(R.string.guide_notifications_title),
|
||||
explanation = context.getString(R.string.guide_notifications_body),
|
||||
icon = glyph(Icons.Filled.Notifications),
|
||||
permissions = arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
),
|
||||
@@ -260,11 +256,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!hasNotificationAccess(context)) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Messages.",
|
||||
explanation = "The car shows your messages, reads them aloud and lets you reply. " +
|
||||
"Turn Gearslip on in the list this opens.",
|
||||
title = context.getString(R.string.guide_messages_title),
|
||||
explanation = context.getString(R.string.guide_messages_body),
|
||||
icon = glyph(Icons.Filled.Email),
|
||||
actionLabel = "Open notification access",
|
||||
actionLabel = context.getString(R.string.guide_open_notification_access),
|
||||
onAction = {
|
||||
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||
},
|
||||
@@ -278,11 +273,10 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (androidAutoPackage != null) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Turn off Android Auto.",
|
||||
explanation = "Android Auto is still installed, and it fights Gearslip for the car's " +
|
||||
"USB connection. Disable or uninstall it on the screen this opens.",
|
||||
title = context.getString(R.string.guide_android_auto_title),
|
||||
explanation = context.getString(R.string.guide_android_auto_body),
|
||||
icon = glyph(Icons.Filled.Close),
|
||||
actionLabel = "Open Android Auto's app info",
|
||||
actionLabel = context.getString(R.string.guide_open_android_auto),
|
||||
onAction = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", androidAutoPackage, null)),
|
||||
@@ -295,9 +289,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
// --- Default handler ---------------------------------------------------------------
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Choose Gearslip.",
|
||||
explanation = "If your phone asks which app to open when you plug into the car, pick " +
|
||||
"Gearslip and tap Always.",
|
||||
title = context.getString(R.string.guide_default_title),
|
||||
explanation = context.getString(R.string.guide_default_body),
|
||||
icon = glyph(Icons.Filled.Check),
|
||||
),
|
||||
)
|
||||
@@ -305,8 +298,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
// --- Car audio: always asked, and it decides whether the adb pages below show ---------
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Car audio.",
|
||||
explanation = "Pick how music reaches the car. Calls go over Bluetooth either way.",
|
||||
title = context.getString(R.string.guide_audio_title),
|
||||
explanation = context.getString(R.string.guide_audio_body),
|
||||
icon = glyph(Icons.Filled.PlayArrow),
|
||||
content = { AudioChoice() },
|
||||
),
|
||||
@@ -316,13 +309,11 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!SpeechModels.RECOMMENDED.all { SpeechModels.isReady(context, it) }) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Voice.",
|
||||
explanation = "Ask for directions, music, or a call by voice. Your phone turns speech " +
|
||||
"into text by itself, and nothing you say leaves it. It needs " +
|
||||
"${SpeechModels.RECOMMENDED.sumOf { it.downloadMb }} MB, downloaded once. Use Wi-Fi if you can.",
|
||||
title = context.getString(R.string.guide_voice_title),
|
||||
explanation = context.getString(R.string.guide_voice_body, SpeechModels.RECOMMENDED.sumOf { it.downloadMb }),
|
||||
icon = { MicIcon(it) },
|
||||
optional = true,
|
||||
actionLabel = "Download voice",
|
||||
actionLabel = context.getString(R.string.guide_download_voice),
|
||||
onAction = { SpeechModels.downloadRecommended(context) },
|
||||
content = { VoiceDownloadStatus() },
|
||||
),
|
||||
@@ -333,9 +324,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!hasProjectMediaOp(context)) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "No audio prompt.",
|
||||
explanation = "Run this from a computer with USB debugging on, and Android trusts " +
|
||||
"Gearslip with your audio for good. Without it, you tap Start once each drive.",
|
||||
title = context.getString(R.string.guide_audio_prompt_title),
|
||||
explanation = context.getString(R.string.guide_audio_prompt_body),
|
||||
icon = glyph(Icons.Filled.Build),
|
||||
optional = true,
|
||||
usbOnly = true,
|
||||
@@ -346,10 +336,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!granted(context, arrayOf(Manifest.permission.WRITE_SECURE_SETTINGS))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Readable notifications.",
|
||||
explanation = "While audio goes to the car, Android hides what notifications say, " +
|
||||
"as if you were sharing your screen. This lets Gearslip show them during a " +
|
||||
"drive and hide them again after.",
|
||||
title = context.getString(R.string.guide_readable_title),
|
||||
explanation = context.getString(R.string.guide_readable_body),
|
||||
icon = glyph(Icons.Filled.Build),
|
||||
optional = true,
|
||||
usbOnly = true,
|
||||
@@ -360,12 +348,11 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
if (!hasCallScreeningRole(context)) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Answer calls.",
|
||||
explanation = "Make Gearslip your caller ID app, and the car can answer and decline " +
|
||||
"calls. Without it, calls still ring, but only the phone can answer them.",
|
||||
title = context.getString(R.string.guide_answer_title),
|
||||
explanation = context.getString(R.string.guide_answer_body),
|
||||
icon = glyph(Icons.Filled.Call),
|
||||
optional = true,
|
||||
actionLabel = "Set up",
|
||||
actionLabel = context.getString(R.string.guide_set_up),
|
||||
onAction = onRequestCallScreening,
|
||||
),
|
||||
)
|
||||
@@ -375,16 +362,16 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
// the usage notes. On a replay, the switch shows what was chosen before.
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Good company.",
|
||||
explanation = RECOMMENDED_INTRO,
|
||||
title = context.getString(R.string.guide_apps_title),
|
||||
explanation = context.getString(R.string.recommended_intro),
|
||||
icon = { StarIcon(it) },
|
||||
content = { RecommendedAppsList() },
|
||||
),
|
||||
)
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Your buttons.",
|
||||
explanation = "Pick how the car's buttons look. Change it any time in the car's settings.",
|
||||
title = context.getString(R.string.guide_buttons_title),
|
||||
explanation = context.getString(R.string.guide_buttons_body),
|
||||
icon = { ButtonsIcon(it) },
|
||||
content = { ThemePicker(it.c, it.cq, it.cqSp, key = "guide-buttons") },
|
||||
),
|
||||
@@ -403,9 +390,9 @@ private fun VoiceDownloadStatus() {
|
||||
val progress by SpeechModels.progress.collectAsState()
|
||||
val states = SpeechModels.RECOMMENDED.map { SpeechModels.state(it).collectAsState().value }
|
||||
val line = when {
|
||||
progress != null -> "Downloading, $progress%. You can carry on; it keeps going."
|
||||
states.all { it == SpeechModels.State.Ready } -> "Voice is ready."
|
||||
states.any { it is SpeechModels.State.Failed } -> "The download didn't finish. Tap Download voice to try again."
|
||||
progress != null -> stringResource(R.string.guide_voice_downloading, progress ?: 0)
|
||||
states.all { it == SpeechModels.State.Ready } -> stringResource(R.string.guide_voice_ready)
|
||||
states.any { it is SpeechModels.State.Failed } -> stringResource(R.string.guide_voice_failed)
|
||||
else -> null
|
||||
}
|
||||
LaunchedEffect(Unit) { SpeechModels.init(context) }
|
||||
@@ -421,13 +408,13 @@ private fun AudioChoice() {
|
||||
LaunchedEffect(Unit) { if (!CarSettings.pipeAudioChosen) CarSettings.setPipeAudio(false) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
AudioCard(
|
||||
"Bluetooth", "Recommended",
|
||||
"No prompt each drive, and your map app's voice reaches the car.",
|
||||
stringResource(R.string.guide_audio_bluetooth), stringResource(R.string.guide_recommended),
|
||||
stringResource(R.string.guide_audio_bluetooth_body),
|
||||
selected = !usb,
|
||||
) { CarSettings.setPipeAudio(false) }
|
||||
AudioCard(
|
||||
"USB", null,
|
||||
"Clearer sound with no delay. Each drive, Android asks you to share your screen, which lets Gearslip send the sound.",
|
||||
stringResource(R.string.guide_audio_usb), null,
|
||||
stringResource(R.string.guide_audio_usb_body),
|
||||
selected = usb,
|
||||
) { CarSettings.setPipeAudio(true) }
|
||||
}
|
||||
@@ -535,7 +522,7 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
||||
Spacer(Modifier.height(cq(7f)))
|
||||
if (page.optional) {
|
||||
Text(
|
||||
"OPTIONAL", color = c.primary,
|
||||
stringResource(R.string.guide_optional), color = c.primary,
|
||||
style = TextStyle(fontSize = cqSp(3.4f), fontWeight = FontWeight.Bold, letterSpacing = cqSp(0.4f)),
|
||||
modifier = Modifier.up(entrance(index, 150, 700), cq(7f)),
|
||||
)
|
||||
@@ -564,7 +551,7 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
||||
page.content?.invoke(GuideScale(c, ::cq, ::cqSp))
|
||||
if (page.permissions != null && alreadyGranted) {
|
||||
Text(
|
||||
"Already allowed.", color = c.primary,
|
||||
stringResource(R.string.guide_already_allowed), color = c.primary,
|
||||
style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.SemiBold),
|
||||
)
|
||||
}
|
||||
@@ -585,14 +572,14 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
||||
val next = { index += 1 }
|
||||
when {
|
||||
page.permissions != null && !alreadyGranted -> {
|
||||
PillRow(c, ::cq, ::cqSp, back, "Allow") { launcher.launch(page.permissions) }
|
||||
Pill(c, ::cqSp, "Skip for now", PillStyle.TEXT, Modifier.align(Alignment.CenterHorizontally), next)
|
||||
PillRow(c, ::cq, ::cqSp, back, stringResource(R.string.guide_allow)) { launcher.launch(page.permissions) }
|
||||
Pill(c, ::cqSp, stringResource(R.string.guide_skip), PillStyle.TEXT, Modifier.align(Alignment.CenterHorizontally), next)
|
||||
}
|
||||
page.onAction != null -> {
|
||||
Pill(c, ::cqSp, page.actionLabel ?: "Open", PillStyle.OUTLINED, Modifier.fillMaxWidth().padding(bottom = cq(3f)), page.onAction)
|
||||
PillRow(c, ::cq, ::cqSp, back, "Next", next)
|
||||
Pill(c, ::cqSp, page.actionLabel ?: stringResource(R.string.guide_open), PillStyle.OUTLINED, Modifier.fillMaxWidth().padding(bottom = cq(3f)), page.onAction)
|
||||
PillRow(c, ::cq, ::cqSp, back, stringResource(R.string.guide_next), next)
|
||||
}
|
||||
else -> PillRow(c, ::cq, ::cqSp, back, if (index == pages.lastIndex) "Done" else "Next", next)
|
||||
else -> PillRow(c, ::cq, ::cqSp, back, stringResource(if (index == pages.lastIndex) R.string.guide_done else R.string.guide_next), next)
|
||||
}
|
||||
Spacer(Modifier.height(cq(6f)))
|
||||
}
|
||||
@@ -613,7 +600,7 @@ private fun ScrollHint(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextU
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Text(
|
||||
"Scroll for more ↓",
|
||||
stringResource(R.string.guide_scroll_more),
|
||||
color = c.primary,
|
||||
style = TextStyle(fontSize = cqSp(4f), fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.padding(bottom = cq(2f)).offset(y = cq(1.2f) * bob),
|
||||
@@ -657,7 +644,7 @@ internal fun PillRow(
|
||||
onBack: (() -> Unit)?, label: String, onClick: () -> Unit,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(cq(3f))) {
|
||||
if (onBack != null) Pill(c, cqSp, "Back", PillStyle.OUTLINED, Modifier.weight(1f), onBack)
|
||||
if (onBack != null) Pill(c, cqSp, stringResource(R.string.guide_back), PillStyle.OUTLINED, Modifier.weight(1f), onBack)
|
||||
Pill(c, cqSp, label, PillStyle.FILLED, Modifier.weight(if (onBack != null) 1.4f else 1f), onClick)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -42,7 +44,6 @@ import app.seb3thehacker.gearslip.SessionStatus
|
||||
import app.seb3thehacker.gearslip.update.UpdateChecker
|
||||
import app.seb3thehacker.gearslip.update.UpdateChecker.State
|
||||
|
||||
private const val UNPLUG_FIRST = "Unplug from the car to install. Installing closes Gearslip."
|
||||
|
||||
/** Shown on Home once a newer version is found; hidden otherwise. */
|
||||
@Composable
|
||||
@@ -64,37 +65,37 @@ fun UpdateCard() {
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp)) {
|
||||
Text("Gearslip ${release.version} is out", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.update_card_title, release.version), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
when (val s = state) {
|
||||
is State.Downloading -> {
|
||||
Text("Downloading… ${(s.fraction * 100).toInt()}%", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(stringResource(R.string.update_downloading, (s.fraction * 100).toInt()), style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
LinearProgressIndicator(progress = { s.fraction }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
is State.Ready -> {
|
||||
val connected = UpdateChecker.carConnected
|
||||
Text(
|
||||
if (connected) UNPLUG_FIRST else "Downloaded. Android asks before it installs.",
|
||||
if (connected) stringResource(R.string.update_unplug_first) else stringResource(R.string.update_downloaded_ask),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = { UpdateChecker.install(context) }, enabled = !connected) { Text("Install") }
|
||||
Button(onClick = { UpdateChecker.install(context) }, enabled = !connected) { Text(stringResource(R.string.update_install)) }
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
(s as? State.Failed)?.message ?: "Download it now. Android asks before it installs.",
|
||||
(s as? State.Failed)?.message ?: stringResource(R.string.update_download_now),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { UpdateChecker.download(context, release) }) {
|
||||
Text(if (s is State.Failed) "Try again" else "Update")
|
||||
Text(stringResource(if (s is State.Failed) R.string.update_try_again else R.string.update_update))
|
||||
}
|
||||
if (release.pageUrl.isNotBlank()) {
|
||||
TextButton(onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(release.pageUrl)))
|
||||
}) { Text("What's new") }
|
||||
}) { Text(stringResource(R.string.settings_whats_new)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,13 +107,14 @@ fun UpdateCard() {
|
||||
}
|
||||
|
||||
/** What the update check found, in a line. */
|
||||
@Composable
|
||||
private fun statusLine(state: State): String = when (state) {
|
||||
State.Idle -> if (BuildConfig.DEBUG) "Debug builds check only when you tap Check now" else "Gearslip checks once a day"
|
||||
State.Checking -> "Checking…"
|
||||
State.UpToDate -> "You have the latest version"
|
||||
is State.Available -> "Version ${state.release.version} is out"
|
||||
is State.Downloading -> "Downloading… ${(state.fraction * 100).toInt()}%"
|
||||
is State.Ready -> if (UpdateChecker.carConnected) UNPLUG_FIRST else "Version ${state.release.version} is ready to install"
|
||||
State.Idle -> stringResource(if (BuildConfig.DEBUG) R.string.update_debug_manual else R.string.update_checks_daily)
|
||||
State.Checking -> stringResource(R.string.update_checking)
|
||||
State.UpToDate -> stringResource(R.string.update_latest)
|
||||
is State.Available -> stringResource(R.string.update_version_out, state.release.version)
|
||||
is State.Downloading -> stringResource(R.string.update_downloading, (state.fraction * 100).toInt())
|
||||
is State.Ready -> if (UpdateChecker.carConnected) stringResource(R.string.update_unplug_first) else stringResource(R.string.update_version_ready, state.release.version)
|
||||
is State.Failed -> state.message
|
||||
}
|
||||
|
||||
@@ -135,7 +137,7 @@ internal fun UpdateRow() {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
val channelName = CHANNELS.firstOrNull { it.first == channel }?.second
|
||||
SettingsRow(
|
||||
"Updates",
|
||||
stringResource(R.string.settings_updates),
|
||||
Modifier.clickable { open = true },
|
||||
subtitle = listOfNotNull(channelName, statusLine(state)).joinToString(" · "),
|
||||
icon = Icons.Filled.CheckCircle,
|
||||
@@ -169,22 +171,23 @@ private fun UpdateDialog(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var auto by remember { mutableStateOf(UpdateChecker.autoCheckEnabled(context)) }
|
||||
val actionLabels = listOf(R.string.update_download, R.string.update_install, R.string.update_try_again, R.string.update_check_now).map { stringResource(it) }
|
||||
val action: Pair<String, () -> Unit>? = when (state) {
|
||||
State.Checking, is State.Downloading -> null
|
||||
is State.Available -> "Download" to { UpdateChecker.download(context, state.release) }
|
||||
is State.Ready -> if (UpdateChecker.carConnected) null else "Install" to { UpdateChecker.install(context) }
|
||||
is State.Failed -> "Try again" to {
|
||||
is State.Available -> actionLabels[0] to { UpdateChecker.download(context, state.release) }
|
||||
is State.Ready -> if (UpdateChecker.carConnected) null else actionLabels[1] to { UpdateChecker.install(context) }
|
||||
is State.Failed -> actionLabels[2] to {
|
||||
state.release?.let { UpdateChecker.download(context, it) } ?: UpdateChecker.check(context)
|
||||
}
|
||||
else -> "Check now" to { UpdateChecker.check(context) }
|
||||
else -> actionLabels[3] to { UpdateChecker.check(context) }
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Updates") },
|
||||
title = { Text(stringResource(R.string.settings_updates)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"Version ${BuildConfig.VERSION_NAME}. ${statusLine(state)}.",
|
||||
stringResource(R.string.update_dialog_status, BuildConfig.VERSION_NAME, statusLine(state)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
@@ -218,9 +221,9 @@ private fun UpdateDialog(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Check automatically", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(stringResource(R.string.update_check_auto), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
if (BuildConfig.DEBUG) "Debug builds check only when you tap Check now" else "Once a day",
|
||||
stringResource(if (BuildConfig.DEBUG) R.string.update_debug_manual else R.string.update_once_a_day),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -232,6 +235,6 @@ private fun UpdateDialog(
|
||||
confirmButton = {
|
||||
action?.let { (label, run) -> TextButton(onClick = run) { Text(label) } }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.guide_done)) } },
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -53,21 +56,9 @@ import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
|
||||
internal const val USAGE_STATS_TITLE = "Help show which cars work"
|
||||
|
||||
private const val INTRO_START =
|
||||
"Turn this on, and Gearslip sends a short note when you open it and after each drive. "
|
||||
private const val INTRO_PRIVACY =
|
||||
"The note holds no name, account, or location. A random number counts your phone once, and the server keeps no IP address."
|
||||
private const val INTRO_END =
|
||||
" The notes show which cars work and where the others fail."
|
||||
|
||||
/** The pitch for the usage notes, in setup and Settings, with the privacy promise in bold so it isn't missed. */
|
||||
internal val USAGE_STATS_INTRO: AnnotatedString = buildAnnotatedString {
|
||||
append(INTRO_START)
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(INTRO_PRIVACY) }
|
||||
append(INTRO_END)
|
||||
}
|
||||
internal fun usageStatsIntro(context: Context): AnnotatedString =
|
||||
AnnotatedString.fromHtml(context.getString(R.string.usage_intro))
|
||||
|
||||
/** The usage notes card for the setup guide: the same rows Settings shows. */
|
||||
@Composable
|
||||
@@ -90,9 +81,9 @@ internal fun ColumnScope.UsageStatsRows() {
|
||||
DisposableEffect(Unit) { onDispose { UsageStats.choicesClosed(context) } }
|
||||
|
||||
SettingsRow(
|
||||
"Send usage notes",
|
||||
stringResource(R.string.usage_send),
|
||||
Modifier.toggleable(on, role = Role.Switch) { on = it; save() },
|
||||
subtitle = buildNote(),
|
||||
subtitle = buildNote(context),
|
||||
icon = Icons.Filled.Send,
|
||||
trailing = { Switch(checked = on, onCheckedChange = null) },
|
||||
)
|
||||
@@ -100,31 +91,31 @@ internal fun ColumnScope.UsageStatsRows() {
|
||||
Column {
|
||||
SettingsDivider()
|
||||
Text(
|
||||
"Also share",
|
||||
stringResource(R.string.usage_also_share),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 56.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
Choice("Android version and system", choices.android) { choices = choices.copy(android = it); save() }
|
||||
Choice("Phone model", choices.phone) { choices = choices.copy(phone = it); save() }
|
||||
Choice("Each car and whether it connected", choices.cars) {
|
||||
Choice(stringResource(R.string.usage_share_android), choices.android) { choices = choices.copy(android = it); save() }
|
||||
Choice(stringResource(R.string.usage_share_phone), choices.phone) { choices = choices.copy(phone = it); save() }
|
||||
Choice(stringResource(R.string.usage_share_cars), choices.cars) {
|
||||
choices = choices.copy(cars = it)
|
||||
save()
|
||||
}
|
||||
Choice("Each drive and how it ended", choices.failures) {
|
||||
Choice(stringResource(R.string.usage_share_drives), choices.failures) {
|
||||
choices = choices.copy(failures = it)
|
||||
save()
|
||||
}
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"What gets sent",
|
||||
stringResource(R.string.usage_what_sent),
|
||||
Modifier.clickable { noteOpen = !noteOpen },
|
||||
subtitle = "Every field, exactly as it goes out",
|
||||
subtitle = stringResource(R.string.usage_what_sent_sub),
|
||||
icon = Icons.Filled.Info,
|
||||
trailing = {
|
||||
Icon(
|
||||
if (noteOpen) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (noteOpen) "Hide the note" else "Show the note",
|
||||
contentDescription = stringResource(if (noteOpen) R.string.usage_hide_note else R.string.usage_show_note),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
@@ -167,15 +158,15 @@ private fun notePreview(context: Context): String {
|
||||
val preview = UsageStats.preview(context, everything = true)
|
||||
// Until the first drive the list is empty, so show what one entry looks like.
|
||||
val example = if (preview.optJSONArray("sessions")?.length() == 0) {
|
||||
"\n\nExample, not sent: one drive adds this to \"sessions\"\n" + UsageStats.exampleSession.toString(2)
|
||||
"\n\n" + context.getString(R.string.usage_example) + "\n" + UsageStats.exampleSession.toString(2)
|
||||
} else ""
|
||||
return preview.toString(2) + example
|
||||
}
|
||||
|
||||
/** What's different about where this build's notes go; null for a normal release. */
|
||||
private fun buildNote(): String? = when {
|
||||
BuildConfig.STATS_URL.isBlank() -> "This build has no stats server, so it sends nothing"
|
||||
BuildConfig.DEBUG -> "Debug build: notes are kept apart and never counted in the stats"
|
||||
private fun buildNote(context: Context): String? = when {
|
||||
BuildConfig.STATS_URL.isBlank() -> context.getString(R.string.usage_no_server)
|
||||
BuildConfig.DEBUG -> context.getString(R.string.usage_debug_build)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -197,18 +188,18 @@ internal fun WorkedCard(car: String, onSeeWhatsSent: () -> Unit, modifier: Modif
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Check, contentDescription = null)
|
||||
Text(
|
||||
WorkedPrompt.TITLE_START + car,
|
||||
if (car.isEmpty()) stringResource(R.string.worked_title_unknown) else stringResource(R.string.worked_title, car),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Share that so the next driver knows it works? No name, account, or location.",
|
||||
stringResource(R.string.worked_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
buildNote()?.let {
|
||||
buildNote(context)?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
Row(
|
||||
@@ -217,11 +208,11 @@ internal fun WorkedCard(car: String, onSeeWhatsSent: () -> Unit, modifier: Modif
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(onClick = onSeeWhatsSent, contentPadding = PaddingValues(horizontal = 0.dp)) {
|
||||
Text("What gets sent")
|
||||
Text(stringResource(R.string.usage_what_sent))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
OutlinedButton(onClick = { WorkedPrompt.answer(context, share = false) }) { Text("Not now") }
|
||||
Button(onClick = { WorkedPrompt.answer(context, share = true) }) { Text("Share") }
|
||||
OutlinedButton(onClick = { WorkedPrompt.answer(context, share = false) }) { Text(stringResource(R.string.worked_not_now)) }
|
||||
Button(onClick = { WorkedPrompt.answer(context, share = true) }) { Text(stringResource(R.string.worked_share)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -35,11 +37,9 @@ internal fun VoiceModelSection() {
|
||||
remember { SpeechModels.init(context) }
|
||||
val selected by SpeechModels.selected.collectAsState()
|
||||
val final by SpeechModels.final.collectAsState()
|
||||
SettingsSection(title = "Voice: live words") {
|
||||
SettingsSection(title = stringResource(R.string.voice_live_title)) {
|
||||
Text(
|
||||
"The voice assistant turns speech into text on this phone, and nothing you say leaves it. " +
|
||||
"It needs two models, about 270 MB together, downloaded once. " +
|
||||
"This one writes your words as you speak.",
|
||||
stringResource(R.string.voice_live_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
@@ -49,10 +49,9 @@ internal fun VoiceModelSection() {
|
||||
ModelRow(model, model == selected)
|
||||
}
|
||||
}
|
||||
SettingsSection(title = "Voice: final words") {
|
||||
SettingsSection(title = stringResource(R.string.voice_final_title)) {
|
||||
Text(
|
||||
"When you stop talking, this model reads the whole phrase again. It catches names and " +
|
||||
"places the live model misses.",
|
||||
stringResource(R.string.voice_final_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
@@ -67,9 +66,9 @@ internal fun VoiceModelSection() {
|
||||
) {
|
||||
RadioButton(final == null, onClick = null, modifier = Modifier.padding(8.dp))
|
||||
Column {
|
||||
Text("None", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.voice_none), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Keep the live words. Fastest, least accurate",
|
||||
stringResource(R.string.voice_none_sub),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -95,12 +94,12 @@ private fun ModelRow(model: Model, chosen: Boolean) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(chosen, onClick = null, modifier = Modifier.padding(8.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("${model.label} · ${model.downloadMb} MB", style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.voice_model_size, model.label, model.downloadMb), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
when (val s = state) {
|
||||
State.Ready -> "Downloaded. ${model.detail}"
|
||||
is State.Downloading -> "Downloading, ${s.percent}%"
|
||||
is State.Failed -> "Download failed: ${s.reason}"
|
||||
State.Ready -> stringResource(R.string.voice_downloaded, model.detail)
|
||||
is State.Downloading -> stringResource(R.string.voice_downloading, s.percent)
|
||||
is State.Failed -> stringResource(R.string.cert_download_failed, s.reason)
|
||||
State.Missing -> model.detail
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -109,9 +108,9 @@ private fun ModelRow(model: Model, chosen: Boolean) {
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
when (state) {
|
||||
State.Ready -> TextButton(onClick = { SpeechModels.delete(context, model) }) { Text("Delete") }
|
||||
is State.Downloading -> TextButton(onClick = { SpeechModels.delete(context, model) }) { Text("Cancel") }
|
||||
else -> OutlinedButton(onClick = { SpeechModels.download(context, model) }) { Text("Download") }
|
||||
State.Ready -> TextButton(onClick = { SpeechModels.delete(context, model) }) { Text(stringResource(R.string.storage_delete)) }
|
||||
is State.Downloading -> TextButton(onClick = { SpeechModels.delete(context, model) }) { Text(stringResource(R.string.cancel)) }
|
||||
else -> OutlinedButton(onClick = { SpeechModels.download(context, model) }) { Text(stringResource(R.string.update_download)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -37,10 +39,10 @@ fun WhatsNewScreen(onBack: () -> Unit) {
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("What's new") },
|
||||
title = { Text(stringResource(R.string.settings_whats_new)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -58,7 +60,7 @@ fun WhatsNewScreen(onBack: () -> Unit) {
|
||||
Changelog.releases.forEach { release ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
if (release.versionCode == BuildConfig.VERSION_CODE) "${release.versionName} (this version)" else release.versionName,
|
||||
if (release.versionCode == BuildConfig.VERSION_CODE) stringResource(R.string.whats_new_this_version, release.versionName) else release.versionName,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
@@ -64,7 +66,7 @@ internal fun WirelessButton() {
|
||||
|
||||
if (denied) {
|
||||
Text(
|
||||
"Gearslip needs Bluetooth and nearby Wi-Fi access for this.",
|
||||
stringResource(R.string.wireless_needs_permission),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
// Once Android stops asking, the only way back is the app's own permission settings.
|
||||
@@ -77,7 +79,7 @@ internal fun WirelessButton() {
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
) { Text("Enable Nearby devices permission") }
|
||||
) { Text(stringResource(R.string.wireless_enable_permission)) }
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -88,17 +90,17 @@ internal fun WirelessButton() {
|
||||
) {
|
||||
Box(Modifier.size(28.dp)) { WifiIcon(MaterialTheme.colorScheme.onPrimary) }
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text("Connect wirelessly", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.wireless_connect), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
devices?.let { list ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { devices = null },
|
||||
title = { Text("Which car?") },
|
||||
title = { Text(stringResource(R.string.wireless_which_car)) },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
if (list.isEmpty()) {
|
||||
Text("Your phone isn't paired with anything yet. Pair it with the car first.")
|
||||
Text(stringResource(R.string.wireless_none_paired))
|
||||
}
|
||||
list.forEach { device ->
|
||||
Text(
|
||||
@@ -118,7 +120,7 @@ internal fun WirelessButton() {
|
||||
// The car has to be paired before it shows up here, and pairing happens in Android's settings.
|
||||
if (list.isNotEmpty()) {
|
||||
Text(
|
||||
"Car not listed? Pair it with your phone first.",
|
||||
stringResource(R.string.wireless_not_listed),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
@@ -132,9 +134,9 @@ internal fun WirelessButton() {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}) { Text("Bluetooth settings") }
|
||||
}) { Text(stringResource(R.string.bluetooth_settings)) }
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { devices = null }) { Text("Cancel") } },
|
||||
confirmButton = { TextButton(onClick = { devices = null }) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user