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:
Seb3thehacker committed 2026-10-26 12:23:20 -07:00
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)) } },
)
}
}