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