From 3fbabfa0edccb4e559e03a067ea70c7eff34e866 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Mon, 26 Oct 2026 13:04:29 -0700 Subject: [PATCH] car: the car screen's text and the spoken directions come from strings.xml, ready for translation; voice commands stay English --- .../gearslip/car/BluetoothPairOverlay.kt | 36 +- .../seb3thehacker/gearslip/car/CallOverlay.kt | 10 +- .../seb3thehacker/gearslip/car/CarAppStage.kt | 6 +- .../app/seb3thehacker/gearslip/car/CarApps.kt | 12 +- .../gearslip/car/CarBluetooth.kt | 14 +- .../gearslip/car/CarDashboardScreen.kt | 24 +- .../gearslip/car/CarHomeScreen.kt | 27 +- .../seb3thehacker/gearslip/car/CarKeyboard.kt | 8 +- .../seb3thehacker/gearslip/car/CarLauncher.kt | 11 +- .../seb3thehacker/gearslip/car/CarNavBar.kt | 26 +- .../gearslip/car/CarNavigator.kt | 31 +- .../gearslip/car/CarSettingsScreen.kt | 167 ++++--- .../app/seb3thehacker/gearslip/car/CarUi.kt | 21 +- .../seb3thehacker/gearslip/car/CarWhatsNew.kt | 8 +- .../gearslip/car/DirectionsVoice.kt | 106 +++-- .../seb3thehacker/gearslip/car/MediaBrowse.kt | 23 +- .../seb3thehacker/gearslip/car/MediaScreen.kt | 66 +-- .../gearslip/car/MessagePopup.kt | 42 +- .../gearslip/car/MessagesScreen.kt | 25 +- .../app/seb3thehacker/gearslip/car/NavApps.kt | 39 +- .../gearslip/car/NotificationsScreen.kt | 20 +- .../gearslip/car/PhoneAppsScreen.kt | 8 +- .../gearslip/car/PhoneDialerScreen.kt | 31 +- .../seb3thehacker/gearslip/car/PlayerParts.kt | 14 +- .../gearslip/car/ScreenHeader.kt | 4 +- .../gearslip/car/TemplateChrome.kt | 16 +- .../gearslip/car/TemplateContent.kt | 10 +- .../gearslip/car/TemplateParts.kt | 4 +- .../gearslip/car/TemplateRows.kt | 4 +- .../app/seb3thehacker/gearslip/car/Weather.kt | 30 +- .../gearslip/car/WeatherScreen.kt | 26 +- .../app/seb3thehacker/gearslip/car/WebApp.kt | 14 +- .../gearslip/ui/HomeCarSettings.kt | 2 +- gearslip/src/main/res/values/strings.xml | 408 ++++++++++++++++++ 34 files changed, 900 insertions(+), 393 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothPairOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothPairOverlay.kt index ee35562..0b8980a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothPairOverlay.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/BluetoothPairOverlay.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -45,32 +47,28 @@ internal fun BluetoothPairOverlay() { val locationOff by LocationCheck.off.collectAsState() val locationDismissed by LocationCheck.carDismissed.collectAsState() if (missing && bluetoothAudio && warn && !dismissed) PairCard( - title = "Bluetooth isn't connected", - body = "Music plays over Bluetooth, but your phone isn't connected to the car. " + - "Connect it from the car's Bluetooth menu or your phone's.", - secondary = "Don't show again" to BluetoothAudioCheck::neverAgain, - primary = "OK" to BluetoothAudioCheck::dismissOnCar, + title = stringResource(R.string.home_bt_title), + body = stringResource(R.string.overlay_bt_body), + secondary = stringResource(R.string.overlay_dont_show) to BluetoothAudioCheck::neverAgain, + primary = stringResource(R.string.overlay_ok) to BluetoothAudioCheck::dismissOnCar, ) else if (locationOff && !locationDismissed) PairCard( - title = "Location is off", - body = "Map apps can't show where you are or give directions. Turn on location " + - "in your phone's quick settings when you're parked.", - secondary = "Don't show again" to LocationCheck::neverAgain, - primary = "OK" to LocationCheck::dismissOnCar, + title = stringResource(R.string.overlay_location_off), + body = stringResource(R.string.overlay_location_body), + secondary = stringResource(R.string.overlay_dont_show) to LocationCheck::neverAgain, + primary = stringResource(R.string.overlay_ok) to LocationCheck::dismissOnCar, ) } CarBluetooth.Prompt.ASK -> PairCard( - title = "Pair with this car?", - body = (if (bluetoothAudio) "Calls and music play" else "Calls play") + - " through the car's speakers once your phone and the car are paired. Your phone " + - "then asks you to confirm a code.", - secondary = "Not now" to CarBluetooth::notNow, - primary = "Pair" to CarBluetooth::pair, + title = stringResource(R.string.overlay_pair_title), + body = stringResource(if (bluetoothAudio) R.string.overlay_pair_body_music else R.string.overlay_pair_body), + secondary = stringResource(R.string.worked_not_now) to CarBluetooth::notNow, + primary = stringResource(R.string.overlay_pair) to CarBluetooth::pair, ) CarBluetooth.Prompt.PAIRING -> PairCard( - title = "Confirm on your phone", - body = "Check the code on your phone and tap Pair. The car may ask too.", + title = stringResource(R.string.overlay_confirm_title), + body = stringResource(R.string.overlay_confirm_body), secondary = null, - primary = "Hide" to CarBluetooth::hide, + primary = stringResource(R.string.carset_hide) to CarBluetooth::hide, ) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt index 28dbcd9..141d5a3 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import androidx.compose.foundation.shape.CircleShape import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.foundation.layout.Arrangement @@ -63,7 +65,7 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline ) Spacer(Modifier.height(16.dp)) Text( - "Incoming call", + stringResource(R.string.call_incoming), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -76,13 +78,13 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline ) Spacer(Modifier.height(56.dp)) Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) { - CallButton(Icons.Filled.Close, "Decline", MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline) - CallButton(Icons.Filled.Call, "Answer", MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer) + CallButton(Icons.Filled.Close, stringResource(R.string.call_decline), MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline) + CallButton(Icons.Filled.Call, stringResource(R.string.call_answer), MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer) } if (!s.canDecline) { Spacer(Modifier.height(20.dp)) Text( - "Already ringing out loud - answer here, or let it ring.", + stringResource(R.string.call_ringing), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt index 64c7953..00c3b4e 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.* import android.view.SurfaceHolder import android.view.SurfaceView @@ -249,14 +251,14 @@ private fun ChromeBar( verticalAlignment = Alignment.CenterVertically, ) { Text( - status.app ?: "Car app", + status.app ?: stringResource(R.string.stage_car_app), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), ) GsIconButton( androidx.compose.material.icons.Icons.Filled.Close, - "Close ${status.app ?: "the app"}", + status.app?.let { stringResource(R.string.stage_close, it) } ?: stringResource(R.string.stage_close_app), onDisconnect, size = 36.dp, iconSize = 20.dp, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarApps.kt index bea237a..07d92f9 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarApps.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.annotation.StringRes +import app.seb3thehacker.gearslip.R import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Search @@ -13,7 +15,9 @@ import app.seb3thehacker.gearslip.ProjectedContent /** One tile in the launcher and the screen it opens. */ class CarApp( val id: String, + /** English, for matching what a driver says; [labelRes] is what the screen shows. */ val label: String, + @param:StringRes val labelRes: Int, val icon: ImageVector, val content: @Composable () -> Unit, ) @@ -22,16 +26,16 @@ class CarApp( object CarApps { val all: List = listOf( - CarApp("web", "Browser", Icons.Filled.Search) { + CarApp("web", "Browser", R.string.app_browser, Icons.Filled.Search) { WebApp(content = ProjectedContent.resolve(LocalContext.current)) }, - CarApp("phone", "Screen sharing", Icons.Filled.Share) { + CarApp("phone", "Screen sharing", R.string.app_screen_sharing, Icons.Filled.Share) { PhoneAppsScreen() }, - CarApp("dialer", "Phone", Icons.Filled.Call) { + CarApp("dialer", "Phone", R.string.carset_phone, Icons.Filled.Call) { PhoneDialerScreen() }, - CarApp("settings", "Settings", Icons.Filled.Settings) { + CarApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings) { CarSettingsScreen() }, ) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt index f211656..14b2e5f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarBluetooth.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.annotation.StringRes +import app.seb3thehacker.gearslip.R import android.Manifest import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter @@ -111,7 +113,7 @@ object CarBluetooth { // If the car never answers on its channel, pair from the phone anyway. main.postDelayed({ startBond("the car didn't answer the pairing request") }, CAR_REPLY_WAIT_MS) main.postDelayed({ - if (pairing) finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.") + if (pairing) finish(R.string.bt_pair_unfinished) }, GIVE_UP_MS) } @@ -168,7 +170,7 @@ object CarBluetooth { bondStarted = true val started = runCatching { device.createBond() }.getOrDefault(false) log.i("pairing from the phone ($why): ${if (started) "started" else "refused"}") - if (!started) finish("Pairing didn't start. Pair from the car's Bluetooth menu instead.") + if (!started) finish(R.string.bt_pair_not_started) } private fun watchBonding(device: BluetoothDevice) { @@ -184,8 +186,8 @@ object CarBluetooth { log.i("bond state $previous -> $state") when { state == BluetoothDevice.BOND_BONDING -> bondStarted = true - state == BluetoothDevice.BOND_BONDED -> finish("Paired. Calls now play through the car.") - previous == BluetoothDevice.BOND_BONDING -> finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.") + state == BluetoothDevice.BOND_BONDED -> finish(R.string.bt_paired) + previous == BluetoothDevice.BOND_BONDING -> finish(R.string.bt_pair_unfinished) } } } @@ -193,12 +195,12 @@ object CarBluetooth { receiver = r } - private fun finish(message: String) { + private fun finish(@StringRes message: Int) { pairing = false stopWatching() main.removeCallbacksAndMessages(null) _prompt.value = Prompt.NONE - CarToasts.show(this, message, CarToast.LENGTH_LONG) + CarToasts.show(this, appContext?.getString(message) ?: return, CarToast.LENGTH_LONG) } private fun stopWatching() { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt index d79b3d3..209ec4c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import android.Manifest import android.content.Context import android.content.pm.PackageManager @@ -60,7 +62,7 @@ fun CarDashboardScreen() { .padding(horizontal = 24.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(24.dp), ) { - ScreenHeader("Today") + ScreenHeader(stringResource(R.string.dash_today)) WeatherCard(context) AgendaCard(context) NotificationsSection() @@ -84,9 +86,9 @@ private fun WeatherCard(context: Context) { horizontalArrangement = Arrangement.spacedBy(16.dp), ) { when (val s = state) { - WeatherState.Loading -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge) + WeatherState.Loading -> Text(stringResource(R.string.dash_getting_weather), style = MaterialTheme.typography.bodyLarge) is WeatherState.Unavailable -> Text( - s.reason, style = MaterialTheme.typography.bodyLarge, + stringResource(s.reason), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) is WeatherState.Ready -> { @@ -94,7 +96,7 @@ private fun WeatherCard(context: Context) { WeatherIcon(w.code, w.isDay, size = 44.dp) Column { Text( - "${w.temp}${w.tempUnit} · ${Weather.describe(w.code)}", + "${w.temp}${w.tempUnit} · ${Weather.condition(w.code)}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, ) Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) @@ -112,12 +114,12 @@ private fun AgendaCard(context: Context) { } Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Coming up", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Text(stringResource(R.string.dash_coming_up), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) val list = events when { - list == null -> Text("Loading calendar…", style = MaterialTheme.typography.bodyMedium) + list == null -> Text(stringResource(R.string.dash_loading_calendar), style = MaterialTheme.typography.bodyMedium) list.isEmpty() -> Text( - "Nothing in the next week.", + stringResource(R.string.dash_nothing_week), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -135,7 +137,7 @@ private fun EventRow(event: AgendaEvent) { verticalAlignment = Alignment.CenterVertically, ) { Text( - eventWhen(event), + eventWhen(event, stringResource(R.string.dash_all_day)), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, ) @@ -149,14 +151,14 @@ private fun EventRow(event: AgendaEvent) { } } -/** "Today" events show just a time; later days get a weekday so a week's list stays readable. */ -private fun eventWhen(event: AgendaEvent): String { +/** stringResource(R.string.dash_today) events show just a time; later days get a weekday so a week's list stays readable. */ +private fun eventWhen(event: AgendaEvent, allDay: String): String { val zone = if (event.allDay) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() val start = Calendar.getInstance(zone).apply { timeInMillis = event.startMillis } val today = Calendar.getInstance() val sameDay = start.get(Calendar.YEAR) == today.get(Calendar.YEAR) && start.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR) - val time = if (event.allDay) "All day" else DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(event.startMillis)) + val time = if (event.allDay) allDay else DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(event.startMillis)) if (sameDay || (event.allDay && start.before(today))) return time return SimpleDateFormat("EEE", Locale.getDefault()).apply { timeZone = zone }.format(Date(event.startMillis)) + " " + time } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt index af48399..1047e65 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -169,19 +170,19 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame val scope = androidx.compose.runtime.rememberCoroutineScope() when (status.phase) { CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE -> - Text("Starting ${status.app ?: "the map"}…", style = MaterialTheme.typography.titleMedium) + Text(status.app?.let { stringResource(R.string.home_starting, it) } ?: stringResource(R.string.home_starting_map), style = MaterialTheme.typography.titleMedium) CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> { - Text("${status.app ?: "The map app"} would not start.", style = MaterialTheme.typography.titleMedium) + Text(status.app?.let { stringResource(R.string.home_failed, it) } ?: stringResource(R.string.home_failed_map), style = MaterialTheme.typography.titleMedium) status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } Spacer(Modifier.height(8.dp)) - StatusButton("Choose another app", GsTone.Primary, navigator::apps) + StatusButton(stringResource(R.string.home_choose_app), GsTone.Primary, navigator::apps) } else -> if (!hasNavApps) { // Nothing to auto-select and nothing to pick from Car apps either - this is the // only case worth a distinct screen, since every other IDLE case resolves itself. - Text("No mapping apps found.", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.home_no_maps), style = MaterialTheme.typography.titleMedium) Text( - "Install a navigation app that supports Android Auto and it will start here on its own.", + stringResource(R.string.home_no_maps_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -189,17 +190,17 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame // IDLE with a last app on record means the driver stopped it themselves - it will // not come back on its own, so the screen must offer a way to bring it back rather // than sit on a "starting" message that never resolves. - Text("Map stopped", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.home_map_stopped), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text( - "It stays closed until you open it again.", + stringResource(R.string.home_map_stopped_body), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(8.dp)) // Side by side, the same size, the likely one lit: one clear choice, not a stack. Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { - StatusButton("Reopen the map", GsTone.Primary) { scope.launch { CarServices.reconnectNav(frame) } } - StatusButton("Car apps", GsTone.Tonal) { navigator.apps() } + StatusButton(stringResource(R.string.home_reopen_map), GsTone.Primary) { scope.launch { CarServices.reconnectNav(frame) } } + StatusButton(stringResource(R.string.home_car_apps), GsTone.Tonal) { navigator.apps() } } } else { // Autostart is still picking one - a navigation app is installed, so this resolves @@ -274,13 +275,13 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) { Spacer(Modifier.height(10.dp)) PlayModeRow(now, media, size = 50.dp, modifier = Modifier.fillMaxWidth()) { GsIconButton( - ImageVector.vectorResource(R.drawable.lyrics_24), "Lyrics", navigator::showSideLyrics, + ImageVector.vectorResource(R.drawable.lyrics_24), stringResource(R.string.storage_lyrics), navigator::showSideLyrics, size = 50.dp, iconSize = 26.dp, ) } } GsIconButton( - Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel, + Icons.Filled.Close, stringResource(R.string.home_back_nav), navigator::closeSidePanel, Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp, ) } @@ -313,9 +314,9 @@ private fun SideLyrics(now: NowPlaying, modifier: Modifier) { maxLines = 1, overflow = TextOverflow.Ellipsis, ) // The way back to the player, as the player's own Lyrics button is the way here. - GsIconButton(MediaIcons.MusicNote, "Show the player", navigator::showSidePlayer, size = 36.dp, iconSize = 20.dp) + GsIconButton(MediaIcons.MusicNote, stringResource(R.string.home_show_player), navigator::showSidePlayer, size = 36.dp, iconSize = 20.dp) Spacer(Modifier.size(6.dp)) - GsIconButton(Icons.Filled.Close, "Hide lyrics", navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp) + GsIconButton(Icons.Filled.Close, stringResource(R.string.home_hide_lyrics), navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp) } LyricsPanel(now, Modifier.weight(1f).fillMaxWidth(), lineStyle = MaterialTheme.typography.headlineSmall) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt index e0a984c..85aa0a6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -153,7 +155,7 @@ fun CarKeyboard( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols } - Key("space", Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") } + Key(stringResource(R.string.keyboard_space), Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") } if (onDismiss != null) { DismissKey(onDismiss, Modifier.weight(1.4f)) } @@ -200,7 +202,7 @@ private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) { horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - Icon(Icons.Filled.Close, contentDescription = "Hide keyboard") + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.keyboard_hide)) } } } @@ -220,7 +222,7 @@ private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, ) { Icon( image ?: if (search) Icons.Filled.Search else Icons.Filled.ArrowBack, - contentDescription = "Submit", + contentDescription = stringResource(R.string.keyboard_submit), ) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt index 46a135d..e4a00e0 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.foundation.Image import androidx.compose.foundation.ExperimentalFoundationApi @@ -127,7 +129,7 @@ internal object LauncherCache { // notifications - rather than a filter per kind that has to know about the others. val tiles = (carApps.keys + mediaApps.keys + messagingApps.keys).flatMap { pkg -> val icon by lazy { iconOf(pkg) } - tilesFor(carApps[pkg], mediaApps[pkg], messagingApps[pkg]).map { tile -> + tilesFor(carApps[pkg], mediaApps[pkg], messagingApps[pkg], context.getString(R.string.media_browse)).map { tile -> when (tile) { is AppTile.Screen -> Entry(tile.label, icon, template = tile.app) is AppTile.Player -> Entry(tile.app.label, icon, media = tile.app) @@ -163,6 +165,7 @@ private fun tilesFor( car: TemplateApp?, media: app.seb3thehacker.gearslip.media.MediaApp?, messaging: app.seb3thehacker.gearslip.notify.MessagingApp?, + browse: String, ): List { if (car != null && !car.isMediaScreen && car.kind != "messaging") return listOf(AppTile.Screen(car, car.label)) val pkg = car?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName ?: return emptyList() @@ -170,7 +173,7 @@ private fun tilesFor( val screenRefused = KnownApps.screenBroken(pkg) && KnownApps.playerWorks(pkg) val messengerScreen = messaging != null && (car?.kind == "messaging" || car?.kind == "app") if (car != null && !messengerScreen && !(media != null && screenRefused)) { - add(AppTile.Screen(car, if (media != null) "${car.label} · Browse" else car.label)) + add(AppTile.Screen(car, if (media != null) "${car.label} · $browse" else car.label)) } if (media != null) add(AppTile.Player(media)) else if (messaging != null) add(AppTile.Messages(messaging)) @@ -280,7 +283,7 @@ fun CarLauncher() { val pkg = entry.packageName?.takeIf { entry.messaging == null } val id = entry.componentId return Tile( - entry.label, entry.icon, entry.builtIn?.glyph, + entry.builtIn?.let { context.getString(it.labelRes) } ?: entry.label, entry.icon, entry.builtIn?.glyph, broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))), onLongClick = id?.let { { navigator.showAppMenu(it) } }, ) { openApp(entry, id?.let { running[it] }, navigator, frame) } @@ -307,7 +310,7 @@ private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = modifier.size(size).background(Color(0xFFD32F2F), CircleShape), contentAlignment = Alignment.Center, ) { - Icon(Icons.Filled.Close, contentDescription = "Doesn't work with Gearslip", tint = Color.White, modifier = Modifier.size(size * 0.66f)) + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.launcher_broken), tint = Color.White, modifier = Modifier.size(size * 0.66f)) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt index 00ff92a..4ba38b3 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavBar.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource import androidx.compose.material.icons.filled.Call import app.seb3thehacker.gearslip.call.CarCalls import app.seb3thehacker.gearslip.call.CallUiState @@ -116,12 +117,12 @@ private fun BreadcrumbBar(navigator: CarNavigator) { Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp) + GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back, size = 32.dp, iconSize = 20.dp) Spacer(Modifier.width(2.dp)) trail.forEachIndexed { index, crumb -> val isLast = index == trail.lastIndex Text( - crumb.label, + crumb.shown(), style = MaterialTheme.typography.labelLarge, fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal, color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, @@ -283,7 +284,7 @@ private fun NavHomeButton(navigator: CarNavigator, entries: List, running } } else { Icon( - ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home", + ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = stringResource(R.string.crumb_home), modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp), ) } @@ -344,7 +345,7 @@ private fun NavAppIcon( onLongClick = onLongClick, ) { entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) } - entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) } + entry.builtIn?.let { Icon(it.glyph, entry.shownLabel(), Modifier.size(26.dp)) } // Only running apps get the line along the bottom edge, the usual taskbar mark for it. if (open) { Box( @@ -495,7 +496,7 @@ private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier) ) { Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp)) Text( - call.caller?.label ?: "On a call", + call.caller?.label ?: stringResource(R.string.navbar_on_call), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, @@ -510,12 +511,12 @@ private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier) Spacer(Modifier.width(2.dp)) GsIconButton( if (muted) MediaIcons.MicOff else MediaIcons.Mic, - if (muted) "Unmute" else "Mute", + stringResource(if (muted) R.string.navbar_unmute else R.string.navbar_mute), CarCalls::toggleMute, tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp, ) GsIconButton( - MediaIcons.CallEnd, "End call", { CarCalls.end(context) }, + MediaIcons.CallEnd, stringResource(R.string.navbar_end_call), { CarCalls.end(context) }, colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp, ) } @@ -523,9 +524,10 @@ private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier) } /** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */ +@Composable private fun nowPlayingLabel(title: String, artist: String): String = listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ") - .ifEmpty { "Nothing playing" } + .ifEmpty { stringResource(R.string.media_nothing_playing) } /** * The player in the nav bar, on every screen. Art and title open the full media screen; @@ -607,19 +609,19 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: } if (!showTitle) { GsIconButton( - MediaIcons.Previous, "Previous", media::previous, + MediaIcons.Previous, stringResource(R.string.player_previous), media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp, ) } GsIconButton( if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, - if (now.playing) "Pause" else "Play", + stringResource(if (now.playing) R.string.player_pause else R.string.media_play), media::togglePlay, tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp, ) if (!showTitle) { GsIconButton( - MediaIcons.Next, "Next", media::next, + MediaIcons.Next, stringResource(R.string.player_next), media::next, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp, ) } @@ -633,7 +635,7 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: docked -> MediaIcons.DockRightOpen else -> MediaIcons.DockRight }, - if (docked) "Close the side panel" else "Move the player beside the map", + stringResource(if (docked) R.string.navbar_close_panel else R.string.navbar_dock_player), navigator::togglePlayerDock, size = 36.dp, iconSize = 22.dp, ) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt index 12e0393..e8e8ee2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt @@ -1,5 +1,9 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.runtime.Composable +import androidx.annotation.StringRes +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -41,7 +45,11 @@ sealed interface CarScreen { enum class SidePanel { NONE, CONTROLS, LYRICS } /** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */ -data class Crumb(val screen: CarScreen, val label: String) +data class Crumb(val screen: CarScreen, val label: String, @param:StringRes val labelRes: Int = 0) { + /** [labelRes] when the step is one of Gearslip's own, translated; [label] for an app's own name. */ + @Composable + fun shown(): String = if (labelRes != 0) stringResource(labelRes) else label +} /** * Where the car UI is, and how it got there. A car touchscreen has no hardware back button, so @@ -57,6 +65,7 @@ class CarNavigator(startId: String) { Crumb( if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home, "Home", + R.string.crumb_home, ), ) @@ -69,15 +78,15 @@ class CarNavigator(startId: String) { /** Pushes a new step, unless it's the same screen already on top - tapping the button for * where you already are shouldn't grow the trail. */ - private fun push(screen: CarScreen, label: String) { + private fun push(screen: CarScreen, label: String, @StringRes labelRes: Int = 0) { if (stack.last().screen == screen) return - stack.add(Crumb(screen, label)) + stack.add(Crumb(screen, label, labelRes)) } /** The one way back to a clean slate: resets the whole trail to just Home. */ fun home() { stack.clear() - stack.add(Crumb(CarScreen.Home, "Home")) + stack.add(Crumb(CarScreen.Home, "Home", R.string.crumb_home)) } /** @@ -117,17 +126,17 @@ class CarNavigator(startId: String) { fun dismissAppMenu() { appMenu = null } - fun media() { push(CarScreen.Media, "Media") } + fun media() { push(CarScreen.Media, "Media", R.string.crumb_media) } fun browse(label: String) { push(CarScreen.Browse, label) } - fun apps() { push(CarScreen.Apps, "Apps") } + fun apps() { push(CarScreen.Apps, "Apps", R.string.crumb_apps) } - fun settings() { push(CarScreen.Settings, "Settings") } + fun settings() { push(CarScreen.Settings, "Settings", R.string.settings_title) } - fun dashboard() { push(CarScreen.Dashboard, "Dashboard") } + fun dashboard() { push(CarScreen.Dashboard, "Dashboard", R.string.crumb_dashboard) } - fun weather() { push(CarScreen.Weather, "Weather") } + fun weather() { push(CarScreen.Weather, "Weather", R.string.crumb_weather) } fun vehicleData() { push(CarScreen.VehicleData, "Vehicle data") } @@ -135,10 +144,10 @@ class CarNavigator(startId: String) { /** The list lives on the dashboard; only a reply gets a screen of its own. */ fun notifications(replyTo: String? = null) { - if (replyTo == null) dashboard() else push(CarScreen.Notifications(replyTo), "Reply") + if (replyTo == null) dashboard() else push(CarScreen.Notifications(replyTo), "Reply", R.string.crumb_reply) } - fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) } + fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id, CarApps.find(id)?.labelRes ?: 0) } /** Jumps straight to a step already on the trail, dropping everything after it - what * tapping a breadcrumb does. Index 0 (Home) is always on the trail, so this always succeeds. */ diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt index 510aa52..7e37c3a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.DarkStyle import app.seb3thehacker.gearslip.car.theme.* @@ -90,7 +92,7 @@ fun CarSettingsScreen() { val listState = androidx.compose.foundation.lazy.rememberLazyListState() androidx.compose.runtime.LaunchedEffect(chosen) { listState.scrollToItem(0) } Row(Modifier.fillMaxSize().padding(start = 16.dp, top = 12.dp, bottom = 12.dp)) { - SettingsRail(listOf("Screen", "Nav bar and player", "Sound and voice", "Warnings", "More"), chosen) { chosen = it } + SettingsRail(listOf(stringResource(R.string.car_group_screen), stringResource(R.string.car_group_nav_bar), stringResource(R.string.car_group_sound), stringResource(R.string.car_group_warnings), stringResource(R.string.car_group_more)), chosen) { chosen = it } LazyColumn( Modifier.weight(1f).fillMaxHeight(), state = listState, @@ -101,40 +103,40 @@ fun CarSettingsScreen() { ) { var section: Int section = 0 - if (section == chosen) item { Group("Screen") } + if (section == chosen) item { Group(stringResource(R.string.car_group_screen)) } if (section == chosen) item { ChoiceRow( - "Open on connect", - listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label }, + stringResource(R.string.car_open_on_connect), + listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.all.filter { it.id != "settings" }.map { it.id to stringResource(it.labelRes) }, open, ) { CarSettings.setOpenOnConnect(it) } } if (section == chosen) item { ChoiceRow( - "Size", - listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"), + stringResource(R.string.car_size), + 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)), scale, ) { CarSettings.setScale(it) } } if (section == chosen) item { ChoiceRow( - "App theme", - listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"), + stringResource(R.string.car_app_theme), + 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)), theme, ) { CarSettings.setAppTheme(it) } } if (section == chosen && theme != AppTheme.LIGHT) item { ChoiceRow( - "Dark mode", + stringResource(R.string.car_dark_mode), listOf( - DarkStyle.BLACK to "Black", - DarkStyle.BLACK_COLOUR to "Black and colour", - DarkStyle.DEEP_COLOUR to "Deep colour", - DarkStyle.GREY to "Grey", + 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), ), darkStyle, ) { CarSettings.setDarkStyle(it) } @@ -142,7 +144,7 @@ fun CarSettingsScreen() { if (section == chosen) item { ChoiceRow( - "Theme", + stringResource(R.string.carset_theme), GsThemes.builtIn.map { it.id to it.name }, uiTheme, ) { CarSettings.setUiTheme(it) } @@ -150,16 +152,16 @@ fun CarSettingsScreen() { if (section == chosen) item { ChoiceRow( - "Map day/night", - listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"), + stringResource(R.string.car_map_day_night), + 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)), night, ) { CarSettings.setNightMode(it) } } if (section == chosen) item { ChoiceRow( - "Units", - listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"), + stringResource(R.string.car_units), + listOf(Units.AUTO to stringResource(R.string.choice_auto), Units.IMPERIAL to stringResource(R.string.carset_imperial), Units.METRIC to stringResource(R.string.carset_metric)), units, ) { CarSettings.setUnits(it) } } @@ -167,31 +169,31 @@ fun CarSettingsScreen() { // For a head unit that reports the wrong size and stretches the picture (issue #12). if (section == chosen) item { ChoiceRow( - "Screen size (next connection)", + stringResource(R.string.carset_screen_size), listOf(null).plus(SCREEN_PRESETS).plus(screenSize).distinct() - .map { it to (it?.toString() ?: "From the car") }, + .map { it to (it?.toString() ?: stringResource(R.string.screen_from_car)) }, screenSize, ) { CarSettings.setScreenSize(it) } } if (section == chosen && screenSize != null) item { ChoiceRow( - "Fit to the screen", - listOf(ScreenFit.CROP to "Crop the edges", ScreenFit.SQUASH to "Squash to fit"), + stringResource(R.string.carset_fit), + listOf(ScreenFit.CROP to stringResource(R.string.screen_crop), ScreenFit.SQUASH to stringResource(R.string.screen_squash)), screenFit, ) { CarSettings.setScreenFit(it) } } section = 1 - if (section == chosen) item { Group("Nav bar and player") } + if (section == chosen) item { Group(stringResource(R.string.car_group_nav_bar)) } if (section == chosen) item { ChoiceRow( - "Nav bar", + stringResource(R.string.car_nav_bar), listOf( - NavBarMode.SHOW to "Always show", - NavBarMode.HIDE_NAVIGATING to "Hide while navigating", - NavBarMode.HIDE to "Hide until tapped", + 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), ), navBar, ) { CarSettings.setNavBarMode(it) } @@ -199,88 +201,85 @@ fun CarSettingsScreen() { if (section == chosen) item { ChoiceRow( - "Player in the nav bar", - listOf(false to "Skip buttons", true to "Song title"), + 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(it) } } if (section == chosen) item { ChoiceRow( - "Player beside the map", - listOf(false to "Right", true to "Left"), + 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(it) } } if (section == chosen) item { ChoiceRow( - "Autoplay on connect", - listOf(false to "Off", true to "On"), + stringResource(R.string.car_autoplay), + listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)), autoplay, ) { CarSettings.setAutoplay(it) } } if (section == chosen) item { ChoiceRow( - "Lyrics from", - listOf(false to "LRCLIB", true to "More sources"), + stringResource(R.string.carset_lyrics_from), + listOf(false to "LRCLIB", true to stringResource(R.string.carset_more_sources)), moreLyrics, ) { CarSettings.setMoreLyrics(it) } } section = 2 - if (section == chosen) item { Group("Sound and voice") } + if (section == chosen) item { Group(stringResource(R.string.car_group_sound)) } if (section == chosen) item { ChoiceRow( - "Car audio", - listOf(false to "Bluetooth", true to "USB"), + stringResource(R.string.car_audio), + listOf(false to stringResource(R.string.guide_audio_bluetooth), true to stringResource(R.string.guide_audio_usb)), pipeAudio, ) { CarSettings.setPipeAudio(it) } } if (section == chosen) item { Text( - "USB sends the phone's sound through Gearslip. Android asks to share your screen each " + - "drive, and a few apps block it. Bluetooth plays as the phone normally would, and " + - "Android never asks.", + stringResource(R.string.carset_audio_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } section = 3 - if (section == chosen) item { Group("Warnings") } + if (section == chosen) item { Group(stringResource(R.string.car_group_warnings)) } if (section == chosen && !pipeAudio) item { ChoiceRow( - "Warn when Bluetooth isn't connected", - listOf(false to "Off", true to "On"), + stringResource(R.string.car_warn_bluetooth), + listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)), bluetoothWarning, ) { CarSettings.setBluetoothWarning(it) } } if (section == chosen) item { ChoiceRow( - "Warn when location is off", - listOf(false to "Off", true to "On"), + stringResource(R.string.car_warn_location), + listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)), locationWarning, ) { CarSettings.setLocationWarning(it) } } if (section == chosen) item { ChoiceRow( - "Spoken directions", - listOf(false to "Off", true to "On"), + stringResource(R.string.car_spoken_directions), + listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)), speakDirections, ) { CarSettings.setSpeakDirections(it) } } if (section == chosen) item { Text( - "Gearslip reads each turn aloud through the car's speakers, and the music dips while it " + - "speaks. Turn it off if your map app's own voice already plays in the car.", + stringResource(R.string.carset_directions_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -288,16 +287,14 @@ fun CarSettingsScreen() { if (section == chosen) item { ExperimentalFeatureRow( - title = "Use the phone's speech recognition", - description = "Listens through the phone's own speech service instead of Gearslip's. On " + - "most phones that's Google's, which may send your voice to Google. GrapheneOS has " + - "none, so Gearslip keeps using its own.", + title = stringResource(R.string.car_phone_speech), + description = stringResource(R.string.carset_phone_speech_note), checked = systemSpeech, ) { CarSettings.setSystemSpeech(it) } } section = 4 - if (section == chosen) item { Group("More") } + if (section == chosen) item { Group(stringResource(R.string.car_group_more)) } if (section == chosen) item { WebPageSetting() @@ -309,8 +306,8 @@ fun CarSettingsScreen() { if (section == chosen) item { ChoiceRow( - "Experimental features", - listOf(false to "Hide", true to "Show"), + stringResource(R.string.car_experimental), + listOf(false to stringResource(R.string.carset_hide), true to stringResource(R.string.carset_show)), experimentalFeatures, ) { CarSettings.setExperimentalFeaturesEnabled(it) } } @@ -321,26 +318,26 @@ fun CarSettingsScreen() { if (section == chosen) item { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) - Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}") - Info("Vehicle", vehicle.ifEmpty { "-" }) - Info("Video", display.ifEmpty { "-" }) - Info("Certificate", cert?.headline ?: "Checking…") + Text(stringResource(R.string.settings_connection), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Info(stringResource(R.string.carset_phone), "${Build.MANUFACTURER} ${Build.MODEL}") + Info(stringResource(R.string.carset_vehicle), vehicle.ifEmpty { "-" }) + Info(stringResource(R.string.carset_video), display.ifEmpty { "-" }) + Info(stringResource(R.string.settings_certificate), cert?.headline ?: stringResource(R.string.cert_checking)) } } if (section == chosen) item { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) - Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME) - Info("Build", app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString()) + Text(stringResource(R.string.settings_about), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Info(stringResource(R.string.carset_version), app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME) + Info(stringResource(R.string.carset_build), app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString()) ExperimentalFeatureRow( - "What's new after updates", - "Show the list of changes once, the first time the car connects after an update.", + stringResource(R.string.carset_whats_new_after), + stringResource(R.string.carset_whats_new_after_sub), whatsNew, ) { CarSettings.setWhatsNewAfterUpdates(it) } GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) { - Text("What's new", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.settings_whats_new), style = MaterialTheme.typography.titleMedium) } } } @@ -381,7 +378,7 @@ private fun SettingsRail(groups: List, chosen: Int, onChoose: (Int) -> U verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - "Settings", + stringResource(R.string.settings_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 14.dp, top = 6.dp, bottom = 10.dp), @@ -454,17 +451,13 @@ private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean verticalArrangement = Arrangement.spacedBy(10.dp), ) { ExperimentalFeatureRow( - title = "Vehicle data", - description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " + - "channel. Field labels (speed, RPM, fuel...) are carried over from other " + - "reverse-engineering projects and unverified against this car - treat them as a " + - "guess, not a fact, until you've watched one change for yourself.", + title = stringResource(R.string.car_vehicle_data), + description = stringResource(R.string.carset_vehicle_data_note), checked = carSensors, ) { CarSettings.setCarSensorsEnabled(it) } ExperimentalFeatureRow( - title = "Adaptive video quality", - description = "Lowers the picture's quality when the car, or its Wi-Fi, can't keep " + - "up, and raises it again once it can. Takes effect on the next connection.", + title = stringResource(R.string.car_adaptive_video), + description = stringResource(R.string.carset_adaptive_note), checked = adaptiveVideo, ) { CarSettings.setAdaptiveVideoEnabled(it) } } @@ -512,7 +505,7 @@ private fun WebPageSetting() { var url by remember { mutableStateOf(AppSettings.startupUrl(context)) } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Web page", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Text(stringResource(R.string.web_page), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) Surface( onClick = { editing = !editing }, shape = MaterialTheme.shapes.large, @@ -520,7 +513,7 @@ private fun WebPageSetting() { modifier = Modifier.fillMaxWidth(), ) { Text( - url.ifEmpty { "DuckDuckGo (default) - tap to set a different page" }, + url.ifEmpty { stringResource(R.string.carset_web_default) }, style = MaterialTheme.typography.bodyLarge, color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -529,7 +522,7 @@ private fun WebPageSetting() { ) } Text( - "Shown by the Browser app on the launcher. Applies on the next connection.", + stringResource(R.string.carset_web_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -572,22 +565,22 @@ private fun ScreenFit(vehicle: String) { } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Screen fit", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) - Stepper("Top", insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) } - Stepper("Bottom", insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) } + Text(stringResource(R.string.carset_screen_fit), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Stepper(stringResource(R.string.carset_top), insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) } + Stepper(stringResource(R.string.carset_bottom), insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) } Text( - "Usable height ${480 - insets.top - insets.bottom}px of 480", + stringResource(R.string.carset_usable_height, 480 - insets.top - insets.bottom), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) GsButton( onClick = { val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets) - saved = if (ok) "Saved to $vehicle" else "Could not save" + saved = if (ok) context.getString(R.string.carset_saved_to, vehicle) else context.getString(R.string.carset_save_failed) }, enabled = known, modifier = Modifier.fillMaxWidth().height(56.dp), - ) { Text(if (known) "Save for this vehicle" else "No vehicle profile matched") } + ) { Text(if (known) stringResource(R.string.carset_save_vehicle) else stringResource(R.string.carset_no_profile)) } saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } } } @@ -599,7 +592,7 @@ private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) { StepButton("−5", Modifier.weight(1f)) { onStep(-5) } StepButton("−1", Modifier.weight(1f)) { onStep(-1) } Text( - "${value}px", + stringResource(R.string.carset_px, value), style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1.3f), textAlign = androidx.compose.ui.text.style.TextAlign.Center, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index 6c148a1..1d86e4d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource import androidx.compose.material.icons.filled.Call import app.seb3thehacker.gearslip.call.CarCalls import app.seb3thehacker.gearslip.call.CallUiState @@ -359,7 +360,7 @@ fun CarUi() { GsButton(onClick = CarFullscreen::exit, modifier = Modifier.height(48.dp)) { Icon(MediaIcons.FullscreenExit, contentDescription = null, modifier = Modifier.size(22.dp)) Spacer(Modifier.width(8.dp)) - Text("Exit full screen", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.carui_exit_fullscreen), style = MaterialTheme.typography.titleMedium) } } } @@ -404,22 +405,20 @@ private fun CarSafetyWizard(onDone: () -> Unit) { modifier = Modifier.fillMaxWidth(), ) { Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Drive safely", style = ChromeType.headline, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.carui_drive_safely), style = ChromeType.headline, fontWeight = FontWeight.Bold) Text( - "Gearslip is unofficial software: it is not made or reviewed by any " + - "carmaker, and it can fail without warning.", + stringResource(R.string.carui_unofficial), style = ChromeType.body, ) Text( - "Never interact with this screen while driving. Set the route or the " + - "song before you go, then keep your attention on the road.", + stringResource(R.string.carui_never_interact), style = ChromeType.body, ) } } Spacer(Modifier.height(24.dp)) GsButton(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) { - Text("I understand", style = ChromeType.body) + Text(stringResource(R.string.carui_understand), style = ChromeType.body) } } } @@ -450,7 +449,7 @@ private fun BrowseAppScreen(navigator: CarNavigator) { } val failed = status.phase == CarAppConnection.Phase.REJECTED || status.phase == CarAppConnection.Phase.FAILED if (failed || timedOut) { - AppWontStart(status.app ?: "This app", leave) + AppWontStart(status.app ?: stringResource(R.string.carui_this_app), leave) return } CarAppStage(CarServices.browse, status, frame, onDisconnect = leave) @@ -466,13 +465,13 @@ private fun AppWontStart(app: String, onBack: () -> Unit) { verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("$app doesn't work with Gearslip yet.", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.carui_app_unsupported, app), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Text( - "It didn't start. Some apps only run on Google's Android Auto.", + stringResource(R.string.carui_app_unsupported_body), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - GsButton(onClick = onBack, tone = GsTone.Tonal) { Text("Back") } + GsButton(onClick = onBack, tone = GsTone.Tonal) { Text(stringResource(R.string.back)) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarWhatsNew.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarWhatsNew.kt index 66cf8bb..e1c9966 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarWhatsNew.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarWhatsNew.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -76,7 +78,7 @@ fun CarWhatsNew() { ) { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { Text( - "What's new in ${release.versionName}", + stringResource(R.string.whatsnew_title, release.versionName), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, ) @@ -99,12 +101,12 @@ fun CarWhatsNew() { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { GsSwitch(afterUpdates, CarSettings::setWhatsNewAfterUpdates) Text( - "Show after updates", + stringResource(R.string.whatsnew_show_after), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f), ) GsButton(onClick = close, modifier = Modifier.height(56.dp)) { - Text("Got it", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.whatsnew_got_it), style = MaterialTheme.typography.titleMedium) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/DirectionsVoice.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/DirectionsVoice.kt index 4aaeda0..be04f4d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/DirectionsVoice.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/DirectionsVoice.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip.car +import app.seb3thehacker.gearslip.R import android.content.Context import android.os.Bundle import android.speech.tts.TextToSpeech @@ -121,7 +122,7 @@ object DirectionsVoice { say(instruction) stage = Stage.DONE } else { - say("In ${spoken(turn.distance)}, $instruction") + say(ahead(turn.distance, instruction)) stage = if (meters != null && meters > PREPARE_FROM_METERS) Stage.FAR else Stage.PREPARED } return @@ -132,7 +133,7 @@ object DirectionsVoice { stage = Stage.DONE } stage == Stage.FAR && meters != null && meters <= PREPARE_AT_METERS -> { - say("In ${spoken(turn.distance)}, $instruction") + say(ahead(turn.distance, instruction)) stage = Stage.PREPARED } } @@ -140,67 +141,79 @@ object DirectionsVoice { /** What to do, in words: built from the manoeuvre when the app names one, else the app's own text. */ private fun instruction(step: Step): String? { + val res = appContext?.resources ?: return null val road = step.road.text() - val onto = if (road.isBlank()) "" else " onto $road" - val toward = if (road.isBlank()) "" else " toward $road" + // Each manoeuvre is a whole sentence, with and without the road, so a translation can + // put the road wherever its language wants it. + fun say(plain: Int, withRoad: Int) = + if (road.isBlank() || withRoad == 0) res.getString(plain) else res.getString(withRoad, road) val maneuver = step.maneuver val built = when (maneuver?.type) { - Maneuver.TYPE_TURN_SLIGHT_LEFT -> "Turn slightly left$onto" - Maneuver.TYPE_TURN_SLIGHT_RIGHT -> "Turn slightly right$onto" - Maneuver.TYPE_TURN_NORMAL_LEFT -> "Turn left$onto" - Maneuver.TYPE_TURN_NORMAL_RIGHT -> "Turn right$onto" - Maneuver.TYPE_TURN_SHARP_LEFT -> "Turn sharply left$onto" - Maneuver.TYPE_TURN_SHARP_RIGHT -> "Turn sharply right$onto" - Maneuver.TYPE_U_TURN_LEFT, Maneuver.TYPE_U_TURN_RIGHT -> "Make a U-turn" - Maneuver.TYPE_KEEP_LEFT -> "Keep left$toward" - Maneuver.TYPE_KEEP_RIGHT -> "Keep right$toward" - Maneuver.TYPE_FORK_LEFT -> "Keep left at the fork$toward" - Maneuver.TYPE_FORK_RIGHT -> "Keep right at the fork$toward" + Maneuver.TYPE_TURN_SLIGHT_LEFT -> say(R.string.dir_slight_left, R.string.dir_slight_left_onto) + Maneuver.TYPE_TURN_SLIGHT_RIGHT -> say(R.string.dir_slight_right, R.string.dir_slight_right_onto) + Maneuver.TYPE_TURN_NORMAL_LEFT -> say(R.string.dir_left, R.string.dir_left_onto) + Maneuver.TYPE_TURN_NORMAL_RIGHT -> say(R.string.dir_right, R.string.dir_right_onto) + Maneuver.TYPE_TURN_SHARP_LEFT -> say(R.string.dir_sharp_left, R.string.dir_sharp_left_onto) + Maneuver.TYPE_TURN_SHARP_RIGHT -> say(R.string.dir_sharp_right, R.string.dir_sharp_right_onto) + Maneuver.TYPE_U_TURN_LEFT, Maneuver.TYPE_U_TURN_RIGHT -> say(R.string.dir_u_turn, 0) + Maneuver.TYPE_KEEP_LEFT -> say(R.string.dir_keep_left, R.string.dir_keep_left_toward) + Maneuver.TYPE_KEEP_RIGHT -> say(R.string.dir_keep_right, R.string.dir_keep_right_toward) + Maneuver.TYPE_FORK_LEFT -> say(R.string.dir_fork_left, R.string.dir_fork_left_toward) + Maneuver.TYPE_FORK_RIGHT -> say(R.string.dir_fork_right, R.string.dir_fork_right_toward) Maneuver.TYPE_ON_RAMP_SLIGHT_LEFT, Maneuver.TYPE_ON_RAMP_NORMAL_LEFT, Maneuver.TYPE_ON_RAMP_SHARP_LEFT, - Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> "Take the ramp on the left$onto" + Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> say(R.string.dir_ramp_left, R.string.dir_ramp_left_onto) Maneuver.TYPE_ON_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, Maneuver.TYPE_ON_RAMP_SHARP_RIGHT, - Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> "Take the ramp on the right$onto" - Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> "Take the exit on the left$toward" - Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> "Take the exit on the right$toward" - Maneuver.TYPE_MERGE_LEFT -> "Merge left$onto" - Maneuver.TYPE_MERGE_RIGHT -> "Merge right$onto" - Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> "Merge$onto" + Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> say(R.string.dir_ramp_right, R.string.dir_ramp_right_onto) + Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> say(R.string.dir_exit_left, R.string.dir_exit_left_toward) + Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> say(R.string.dir_exit_right, R.string.dir_exit_right_toward) + Maneuver.TYPE_MERGE_LEFT -> say(R.string.dir_merge_left, R.string.dir_merge_left_onto) + Maneuver.TYPE_MERGE_RIGHT -> say(R.string.dir_merge_right, R.string.dir_merge_right_onto) + Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> say(R.string.dir_merge, R.string.dir_merge_onto) Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW_WITH_ANGLE, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE -> maneuver.roundaboutExitNumber.takeIf { it in 1..ORDINALS.size } - ?.let { "At the roundabout, take the ${ORDINALS[it - 1]} exit$onto" } - ?: "Go through the roundabout$onto" - Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> "Enter the roundabout" - Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> "Exit the roundabout$onto" - Maneuver.TYPE_STRAIGHT -> "Continue straight$onto" - Maneuver.TYPE_NAME_CHANGE -> "Continue$onto" - Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> "Take the ferry" - Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> "Take the train" - Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> "Your destination is ahead" - Maneuver.TYPE_DESTINATION_LEFT -> "Your destination is on the left" - Maneuver.TYPE_DESTINATION_RIGHT -> "Your destination is on the right" + ?.let { res.getString(ORDINALS[it - 1]) } + ?.let { exit -> + if (road.isBlank()) res.getString(R.string.dir_roundabout_take, exit) + else res.getString(R.string.dir_roundabout_take_onto, exit, road) + } + ?: say(R.string.dir_roundabout_through, R.string.dir_roundabout_through_onto) + Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> say(R.string.dir_roundabout_enter, 0) + Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> say(R.string.dir_roundabout_exit, R.string.dir_roundabout_exit_onto) + Maneuver.TYPE_STRAIGHT -> say(R.string.dir_straight, R.string.dir_straight_onto) + Maneuver.TYPE_NAME_CHANGE -> say(R.string.dir_continue, R.string.dir_continue_onto) + Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> say(R.string.dir_ferry, 0) + Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> say(R.string.dir_train, 0) + Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> say(R.string.dir_destination, 0) + Maneuver.TYPE_DESTINATION_LEFT -> say(R.string.dir_destination_left, 0) + Maneuver.TYPE_DESTINATION_RIGHT -> say(R.string.dir_destination_right, 0) else -> null } return built ?: step.cue.text().ifBlank { null } } + /** "In 300 meters, turn left": the distance ahead of a turn that isn't close yet. */ + private fun ahead(d: Distance, instruction: String): String = + appContext?.getString(R.string.dir_in_distance, spoken(d), instruction) ?: instruction + /** The app's own rounded distance, read the way a person would say it. */ private fun spoken(d: Distance): String { + val res = appContext?.resources ?: return "" val n = d.displayDistance - val number = if (n % 1.0 == 0.0) n.toInt().toString() else "%.1f".format(n) - val one = n == 1.0 + val whole = n % 1.0 == 0.0 + val number = if (whole) n.toInt().toString() else "%.1f".format(n) return when (d.displayUnit) { - Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> if (one) "1 kilometer" else "$number kilometers" + Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> + if (whole) res.getQuantityString(R.plurals.dir_km, n.toInt(), number) else res.getString(R.string.dir_km_decimal, number) Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> when (n) { - 0.25 -> "a quarter mile" - 0.5 -> "half a mile" - 0.75 -> "three quarters of a mile" - 1.0 -> "1 mile" - else -> "$number miles" + 0.25 -> res.getString(R.string.dir_quarter_mile) + 0.5 -> res.getString(R.string.dir_half_mile) + 0.75 -> res.getString(R.string.dir_three_quarter_mile) + else -> if (whole) res.getQuantityString(R.plurals.dir_miles, n.toInt(), number) else res.getString(R.string.dir_miles_decimal, number) } - Distance.UNIT_FEET -> "$number feet" - Distance.UNIT_YARDS -> "$number yards" - else -> "${n.roundToInt()} meters" + Distance.UNIT_FEET -> res.getQuantityString(R.plurals.dir_feet, n.toInt(), number) + Distance.UNIT_YARDS -> res.getQuantityString(R.plurals.dir_yards, n.toInt(), number) + else -> res.getQuantityString(R.plurals.dir_meters, n.roundToInt(), n.roundToInt()) } } @@ -347,5 +360,8 @@ object DirectionsVoice { private const val LEAD_MS = 100L private const val HOLD_MS = 2_000L private const val SYNTH_TIMEOUT_S = 10L - private val ORDINALS = listOf("first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth") + private val ORDINALS = listOf( + R.string.dir_ordinal_1, R.string.dir_ordinal_2, R.string.dir_ordinal_3, R.string.dir_ordinal_4, + R.string.dir_ordinal_5, R.string.dir_ordinal_6, R.string.dir_ordinal_7, R.string.dir_ordinal_8, + ) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaBrowse.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaBrowse.kt index d434840..bc013f2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaBrowse.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaBrowse.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import app.seb3thehacker.gearslip.car.theme.* @@ -86,7 +88,7 @@ internal fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, m if (media.canGoUp || browse.trail.isNotEmpty()) { Row(Modifier.padding(bottom = 12.dp), verticalAlignment = Alignment.CenterVertically) { if (media.canGoUp) { - GsIconButton(Icons.Filled.ArrowBack, "Up", media::up, size = 44.dp, shape = CircleShape) + GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.media_up), media::up, size = 44.dp, shape = CircleShape) Spacer(Modifier.width(14.dp)) } browse.trail.lastOrNull()?.let { @@ -99,13 +101,13 @@ internal fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, m } } when { - browse.loading -> Notice("Loading…", null) + browse.loading -> Notice(stringResource(R.string.media_loading), null) browse.unavailable -> Notice( - "$appLabel keeps its library to itself.", - "Start music in the app on your phone. Play, pause, skip and seek work from here.", + stringResource(R.string.media_keeps_library, appLabel), + stringResource(R.string.media_browse_start), ) - browse.failed -> Notice("Could not load this list.", null) - browse.entries.isEmpty() -> Notice("Nothing here.", null) + browse.failed -> Notice(stringResource(R.string.media_load_failed), null) + browse.entries.isEmpty() -> Notice(stringResource(R.string.media_nothing_here), null) else -> BrowseList(browse.entries, media) } } @@ -228,9 +230,8 @@ internal fun QueuePanel(queue: List, now: NowPlaying, media: CarMedi Column(modifier) { if (upNext.isEmpty()) { Notice( - "Nothing queued.", - "${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " + - "not every app tells Gearslip what comes next.", + stringResource(R.string.media_nothing_queued), + stringResource(if (now.title.isEmpty()) R.string.media_queue_empty_idle else R.string.media_queue_empty_playing), ) } else { LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -260,7 +261,7 @@ private fun QueueRow(track: QueueTrack, media: CarMedia) { Text(track.subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) } } - Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant) + Icon(Icons.Filled.PlayArrow, stringResource(R.string.media_play), tint = MaterialTheme.colorScheme.onSurfaceVariant) } } } @@ -286,7 +287,7 @@ internal fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = } } if (entry.browsable && entry.playable) { - GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) }) + GsIconButton(Icons.Filled.PlayArrow, stringResource(R.string.media_play), { media.playAll(entry) }) } if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt index 98f29d8..cb3a2e5 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import app.seb3thehacker.gearslip.car.theme.* @@ -110,10 +112,9 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { when (phase) { CarMedia.Phase.REJECTED -> when (rejection) { CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice( - "${app.label} keeps its library to itself.", - "Gearslip can still control it once it has Notification access on the phone. " + - "Allow it under Settings > Notifications > Notification access.", - action = "Open on phone" to { + stringResource(R.string.media_keeps_library, app.label), + stringResource(R.string.media_needs_access), + action = stringResource(R.string.media_open_on_phone) to { val app = context.applicationContext runCatching { app.startActivity( @@ -123,17 +124,16 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { }, ) CarMedia.Rejection.NEEDS_APP_RUNNING -> Notice( - "${app.label} isn't running.", - "${app.label} plays through Gearslip's own player, but only while it's " + - "open on the phone. Open it once and it keeps playing here in the car.", - action = "Open ${app.label}" to { media.launchAndConnect(app) }, + stringResource(R.string.media_not_running, app.label), + stringResource(R.string.media_not_running_body, app.label), + action = stringResource(R.string.media_open_app, app.label) to { media.launchAndConnect(app) }, ) CarMedia.Rejection.REFUSED -> Notice( - "${app.label} would not let Gearslip browse it.", - "Some media apps only accept Google's own host.", + stringResource(R.string.media_refused, app.label), + stringResource(R.string.media_refused_body), ) } - else -> Notice("Connecting to ${app.label}…", null) + else -> Notice(stringResource(R.string.media_connecting, app.label), null) } } return@Surface @@ -180,14 +180,14 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { } Column(Modifier.weight(1.2f).fillMaxHeight()) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING } - MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE } - MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE } - MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS } + MediaTab(stringResource(R.string.media_now_playing), tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING } + MediaTab(stringResource(R.string.media_up_next), tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE } + MediaTab(stringResource(R.string.media_browse), tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE } + MediaTab(stringResource(R.string.storage_lyrics), tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS } if (now.canSearch || media.canSearchLibrary) { GsIconButton( Icons.Filled.Search, - "Search ${app.label}", + stringResource(R.string.media_search, app.label), { searching = true }, colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface), size = 44.dp, @@ -218,7 +218,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = Modifier) { val icon = remember(app) { findEntry(app.component.flattenToString())?.icon } Row(modifier, verticalAlignment = Alignment.CenterVertically) { - GsIconButton(Icons.Filled.ArrowBack, "Back", onExit, size = 44.dp) + GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onExit, size = 44.dp) Spacer(Modifier.width(14.dp)) icon?.let { Image(it, null, Modifier.size(26.dp).clip(RoundedCornerShape(7.dp))) @@ -278,10 +278,10 @@ private fun SearchPanel( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - GsIconButton(Icons.Filled.ArrowBack, "Back", onClose, size = ICON_BUTTON_SIZE) + GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onClose, size = ICON_BUTTON_SIZE) SearchField( query, - "Search $appLabel", + stringResource(R.string.media_search, appLabel), Modifier.weight(1f).clickable { typing = true }, onClear = { query = "" @@ -292,12 +292,12 @@ private fun SearchPanel( Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) { val entries = current?.entries when { - trimmed.isEmpty() -> SearchNote("Type a song, artist, album, or playlist.") + trimmed.isEmpty() -> SearchNote(stringResource(R.string.media_search_hint)) unsupported && canPickForYou -> - SearchNote("$appLabel can't list results. Press search, and $appLabel picks what to play.") - unsupported -> SearchNote("$appLabel can't search from the car.") - entries == null -> SearchNote("Searching $appLabel…") - entries.isEmpty() -> SearchNote("$appLabel found nothing for \"$trimmed\".") + SearchNote(stringResource(R.string.media_search_no_list, appLabel)) + unsupported -> SearchNote(stringResource(R.string.media_search_unsupported, appLabel)) + entries == null -> SearchNote(stringResource(R.string.media_searching, appLabel)) + entries.isEmpty() -> SearchNote(stringResource(R.string.media_search_nothing, appLabel, trimmed)) else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 8.dp)) { items(entries) { entry -> EntryRow(entry, media) { @@ -355,8 +355,8 @@ private fun SearchStatus(search: CarMedia.Search, appLabel: String, onDismiss: ( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), ) { Text( - if (search.ignored) "$appLabel didn't play anything for \"${search.query}\"." - else "Searching $appLabel for \"${search.query}\"…", + if (search.ignored) stringResource(R.string.media_search_ignored, appLabel, search.query) + else stringResource(R.string.media_searching_for, appLabel, search.query), style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -411,9 +411,9 @@ internal fun LyricsPanel( } when (val state = lyrics) { - LyricsState.Loading -> Notice("Looking for lyrics…", null) + LyricsState.Loading -> Notice(stringResource(R.string.media_looking_lyrics), null) LyricsState.Missing -> Notice( - if (now.title.isEmpty()) "Nothing playing." else "No lyrics found for this track.", + stringResource(if (now.title.isEmpty()) R.string.media_nothing_playing_dot else R.string.media_no_lyrics), null, ) is LyricsState.Found -> Column(modifier) { @@ -639,7 +639,7 @@ private fun StageControls(now: NowPlaying, media: CarMedia, modifier: Modifier) ) { Column { Text( - now.title.ifEmpty { "Nothing playing" }, + now.title.ifEmpty { stringResource(R.string.media_nothing_playing) }, style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis, ) @@ -652,7 +652,7 @@ private fun StageControls(now: NowPlaying, media: CarMedia, modifier: Modifier) now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) } if (now.title.isEmpty() && now.error == null) { Text( - "Press play to pick up where you left off, or find something in Browse.", + stringResource(R.string.media_press_play), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -683,7 +683,7 @@ private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modif ) { Column { Text( - now.title.ifEmpty { "Nothing playing" }, + now.title.ifEmpty { stringResource(R.string.media_nothing_playing) }, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis, ) @@ -695,7 +695,7 @@ private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modif now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) } if (now.title.isEmpty() && now.error == null) { Text( - "Press play to pick up where you left off, or find something in Browse.", + stringResource(R.string.media_press_play), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -721,7 +721,7 @@ private fun CompactPlayer(now: NowPlaying, media: CarMedia, art: android.graphic Spacer(Modifier.width(16.dp)) Column(Modifier.weight(1f).padding(bottom = 4.dp)) { Text( - now.title.ifEmpty { "Nothing playing" }, + now.title.ifEmpty { stringResource(R.string.media_nothing_playing) }, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 3, overflow = TextOverflow.Ellipsis, ) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagePopup.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagePopup.kt index 367fccf..94e3855 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagePopup.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagePopup.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import android.os.SystemClock import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll @@ -83,9 +85,10 @@ internal fun ReplyGlyph(modifier: Modifier = Modifier) = Icon(ImageVector.vectorResource(R.drawable.voice_selection_24), null, modifier) /** "Alex", or "Alex · 3 new" when a burst of messages from one chat has been merged. */ +@Composable private fun CarNotification.heading(): String { val who = title.ifBlank { appLabel } - return if (burst > 1) "$who · $burst new" else who + return if (burst > 1) pluralStringResource(R.plurals.msg_burst, burst, who, burst) else who } /** The card. It shows the voice reply too, wherever that was started from. */ @@ -106,17 +109,17 @@ fun NotificationPopup(showNew: Boolean, modifier: Modifier = Modifier) { Text(n.heading(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(n.text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) } - GsIconButton(Icons.Filled.Close, "Dismiss", { CarNotifications.dismissPopup(n.id) }, size = 40.dp, iconSize = 20.dp) + GsIconButton(Icons.Filled.Close, stringResource(R.string.msg_dismiss), { CarNotifications.dismissPopup(n.id) }, size = 40.dp, iconSize = 20.dp) } Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { GsButton(onClick = { VoiceReply.read(n) }, tone = GsTone.Neutral, modifier = Modifier.weight(1f)) { ReadGlyph(Modifier.size(20.dp)) - Text("Read", Modifier.padding(start = 8.dp)) + Text(stringResource(R.string.msg_read), Modifier.padding(start = 8.dp)) } if (n.reply != null) { GsButton(onClick = { VoiceReply.reply(n) }, modifier = Modifier.weight(1f)) { ReplyGlyph(Modifier.size(20.dp)) - Text("Reply", Modifier.padding(start = 8.dp)) + Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp)) } } } @@ -191,36 +194,36 @@ private fun VoiceReplyBody(s: VoiceReply.State, n: CarNotification) { when (s.phase) { VoiceReply.Phase.READING, VoiceReply.Phase.READ -> { GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { - Text(if (s.phase == VoiceReply.Phase.READING) "Stop" else "Close") + Text(if (s.phase == VoiceReply.Phase.READING) stringResource(R.string.msg_stop) else stringResource(R.string.close)) } if (n.reply != null) { GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) { ReplyGlyph(Modifier.size(20.dp)) - Text("Reply", Modifier.padding(start = 8.dp)) + Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp)) } } } VoiceReply.Phase.OFFERING -> { - GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("No") } + GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.msg_no)) } GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) { ReplyGlyph(Modifier.size(20.dp)) - Text("Reply", Modifier.padding(start = 8.dp)) + Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp)) } } VoiceReply.Phase.ASKING, VoiceReply.Phase.LISTENING -> - GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Cancel") } + GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.cancel)) } VoiceReply.Phase.CONFIRMING -> { - GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Cancel") } - GsButton(onClick = VoiceReply::sendNow, modifier = wide) { Text("Send now") } + GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.cancel)) } + GsButton(onClick = VoiceReply::sendNow, modifier = wide) { Text(stringResource(R.string.msg_send_now)) } } VoiceReply.Phase.ERROR -> { - GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text("Close") } + GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) { Text(stringResource(R.string.close)) } if (n.reply != null) { - GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, tone = GsTone.Tonal, modifier = wide) { Text("Try again") } + GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, tone = GsTone.Tonal, modifier = wide) { Text(stringResource(R.string.update_try_again)) } GsButton( onClick = { VoiceReply.cancel(); navigator.notifications(replyTo = n.key) }, tone = GsTone.Neutral, modifier = wide, - ) { Text("Type") } + ) { Text(stringResource(R.string.msg_type)) } } } VoiceReply.Phase.SENT, VoiceReply.Phase.IDLE -> Unit @@ -228,13 +231,14 @@ private fun VoiceReplyBody(s: VoiceReply.State, n: CarNotification) { } } +@Composable private fun voiceHeading(s: VoiceReply.State, who: String): String = when (s.phase) { VoiceReply.Phase.READING, VoiceReply.Phase.READ -> who - VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING -> "Reply to $who" - VoiceReply.Phase.LISTENING -> "Listening" - VoiceReply.Phase.CONFIRMING -> "Sending to $who" - VoiceReply.Phase.SENT -> "Sent to $who" - VoiceReply.Phase.ERROR -> "Couldn't reply" + VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING -> stringResource(R.string.msg_reply_to, who) + VoiceReply.Phase.LISTENING -> stringResource(R.string.msg_listening) + VoiceReply.Phase.CONFIRMING -> stringResource(R.string.msg_sending_to, who) + VoiceReply.Phase.SENT -> stringResource(R.string.msg_sent_to, who) + VoiceReply.Phase.ERROR -> stringResource(R.string.msg_reply_failed) VoiceReply.Phase.IDLE -> "" } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagesScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagesScreen.kt index 218ff2f..8d597e9 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagesScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagesScreen.kt @@ -1,5 +1,8 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.* import android.content.Intent import android.provider.Settings @@ -60,14 +63,14 @@ fun MessagesScreen(packageName: String) { val navigator = LocalCarNavigator.current val context = LocalContext.current.applicationContext val entry = remember(packageName) { findEntry(MESSAGING_PREFIX + packageName) } - val label = entry?.label ?: history.firstOrNull { it.packageName == packageName }?.appLabel ?: "Messages" + val label = entry?.label ?: history.firstOrNull { it.packageName == packageName }?.appLabel ?: stringResource(R.string.msg_messages) val threads = history.filter { it.packageName == packageName } LaunchedEffect(history) { CarNotifications.dismissPopupFrom(packageName) } Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back) + GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back) entry?.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) } Text(label, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) } @@ -82,7 +85,7 @@ fun MessagesScreen(packageName: String) { if (threads.isEmpty()) { Text( - "No recent messages. New $label messages appear here as they arrive on the phone.", + stringResource(R.string.msg_none_recent, label), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -146,7 +149,7 @@ private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: () } if (!expanded && lines.size > COLLAPSED_LINES) { Text( - "${lines.size - COLLAPSED_LINES} earlier", + pluralStringResource(R.plurals.msg_earlier, lines.size - COLLAPSED_LINES, lines.size - COLLAPSED_LINES), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) @@ -156,25 +159,25 @@ private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: () } Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) { if (n.replied) { - Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary) + Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.notif_replied), tint = MaterialTheme.colorScheme.primary) } GsButton(onClick = onRead, tone = GsTone.Neutral) { ReadGlyph(Modifier.size(20.dp)) - Text("Read", Modifier.padding(start = 8.dp)) + Text(stringResource(R.string.msg_read), Modifier.padding(start = 8.dp)) } if (n.reply != null) { GsButton(onClick = onReply, tone = GsTone.Tonal) { ReplyGlyph(Modifier.size(20.dp)) - Text("Reply", Modifier.padding(start = 8.dp)) + Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp)) } } if (n.reply != null) { GsButton(onClick = { quick = !quick }, tone = GsTone.Neutral) { - Text(if (quick) "Hide quick replies" else "Quick reply") + Text(stringResource(if (quick) R.string.msg_hide_quick else R.string.msg_quick)) } } if (n.markRead != null) { - GsButton(onClick = onMarkRead, tone = GsTone.Neutral) { Text("Mark read") } + GsButton(onClick = onMarkRead, tone = GsTone.Neutral) { Text(stringResource(R.string.msg_mark_read)) } } } } @@ -184,8 +187,8 @@ private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: () @Composable private fun MessageLine(line: ChatLine, showSender: Boolean) { val who = when { - line.sender == null -> "You: " - showSender -> "${line.sender}: " + line.sender == null -> stringResource(R.string.msg_you) + showSender -> stringResource(R.string.msg_sender, line.sender) else -> "" } Text( diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt index 0a50ca5..ee8409f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt @@ -1,5 +1,8 @@ package app.seb3thehacker.gearslip.car +import androidx.annotation.StringRes +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -65,26 +68,32 @@ private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f */ internal class BuiltInApp( val id: String, + /** English, for matching what a driver says; [labelRes] is what the screen shows. */ val label: String, + @param:StringRes val labelRes: Int, val glyph: ImageVector, val screen: CarScreen?, val open: (CarNavigator) -> Unit, ) internal object BuiltInApps { - val Web = BuiltInApp("web", "Browser", Icons.Filled.Search, CarScreen.App("web")) { it.open("web") } - val ScreenSharing = BuiltInApp("phone", "Screen sharing", Icons.Filled.Share, CarScreen.App("phone")) { it.open("phone") } - val Phone = BuiltInApp("dialer", "Phone", Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") } - val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() } - val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() } + val Web = BuiltInApp("web", "Browser", R.string.app_browser, Icons.Filled.Search, CarScreen.App("web")) { it.open("web") } + val ScreenSharing = BuiltInApp("phone", "Screen sharing", R.string.app_screen_sharing, Icons.Filled.Share, CarScreen.App("phone")) { it.open("phone") } + val Phone = BuiltInApp("dialer", "Phone", R.string.carset_phone, Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") } + val VehicleData = BuiltInApp("vehicle", "Vehicle data", R.string.car_vehicle_data, Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() } + val Settings = BuiltInApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings, CarScreen.Settings) { it.settings() } /** Hands the screen back to the car's own interface, like Android Auto's Exit. */ - val Exit = BuiltInApp("exit", "Exit", CarGlyph, screen = null) { CarFocus.exitToCar() } + val Exit = BuiltInApp("exit", "Exit", R.string.app_exit, CarGlyph, screen = null) { CarFocus.exitToCar() } val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit) val entries: List = all.map { Entry(it.label, icon = null, builtIn = it) } } +/** The name a driver sees: translated for Gearslip's own apps, the app's own name otherwise. */ +@Composable +internal fun Entry.shownLabel(): String = builtIn?.let { stringResource(it.labelRes) } ?: label + /** Vehicle data is experimental: raw, unverified sensor labels (see [CarSensors]). */ @Composable internal fun rememberVehicleDataShown(): Boolean { @@ -215,7 +224,7 @@ internal fun AppMenu(navigator: CarNavigator, running: Map) entry.icon?.let { Image(it, null, Modifier.size(44.dp).clip(RoundedCornerShape(10.dp))) } entry.builtIn?.let { Icon(it.glyph, null, Modifier.size(36.dp)) } Text( - entry.label, + entry.shownLabel(), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(start = 14.dp), @@ -232,9 +241,9 @@ internal fun AppMenu(navigator: CarNavigator, running: Map) ) { Text( when { - isPinned -> "Unpin from nav bar" - barFull -> "Nav bar is full (${CarSettings.MAX_PINNED} pinned)" - else -> "Pin to nav bar" + isPinned -> stringResource(R.string.navapps_unpin) + barFull -> stringResource(R.string.navapps_full, CarSettings.MAX_PINNED) + else -> stringResource(R.string.navapps_pin) }, style = MaterialTheme.typography.titleMedium, ) @@ -248,13 +257,13 @@ internal fun AppMenu(navigator: CarNavigator, running: Map) }, tone = GsTone.Danger, modifier = Modifier.fillMaxWidth().height(56.dp), - ) { Text("Close app", style = MaterialTheme.typography.titleMedium) } + ) { Text(stringResource(R.string.navapps_close), style = MaterialTheme.typography.titleMedium) } } GsButton( onClick = navigator::dismissAppMenu, tone = GsTone.Neutral, modifier = Modifier.fillMaxWidth().height(56.dp), - ) { Text("Cancel", style = MaterialTheme.typography.titleMedium) } + ) { Text(stringResource(R.string.cancel), style = MaterialTheme.typography.titleMedium) } } } } @@ -281,19 +290,19 @@ private fun MoveButtons(id: String) { tone = GsTone.Tonal, enabled = index > 0, modifier = Modifier.weight(1f).height(56.dp), - ) { Text("Move left", style = MaterialTheme.typography.titleMedium) } + ) { Text(stringResource(R.string.navapps_move_left), style = MaterialTheme.typography.titleMedium) } GsButton( onClick = { move(1) }, tone = GsTone.Tonal, enabled = index < order.lastIndex, modifier = Modifier.weight(1f).height(56.dp), - ) { Text("Move right", style = MaterialTheme.typography.titleMedium) } + ) { Text(stringResource(R.string.navapps_move_right), style = MaterialTheme.typography.titleMedium) } } if (custom.isNotEmpty()) { GsButton( onClick = { CarSettings.setAppOrder(emptyList()) }, tone = GsTone.Neutral, modifier = Modifier.fillMaxWidth().height(56.dp), - ) { Text("Reset app order", style = MaterialTheme.typography.titleMedium) } + ) { Text(stringResource(R.string.navapps_reset), style = MaterialTheme.typography.titleMedium) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt index 31ef3a1..c37c793 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.* import android.content.Intent import android.provider.Settings @@ -79,12 +81,12 @@ fun NotificationsSection() { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - "Notifications", + stringResource(R.string.notif_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f), ) - if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text("Clear all") } + if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text(stringResource(R.string.notif_clear_all)) } } if (!listening) { @@ -97,7 +99,7 @@ fun NotificationsSection() { if (history.isEmpty()) { Text( - "Nothing yet. New notifications from the phone will show up here.", + stringResource(R.string.notif_empty), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -124,11 +126,11 @@ internal fun AccessBanner(onOpen: () -> Unit) { ) { Icon(Icons.Filled.Warning, contentDescription = null) Text( - "Notifications are off. Allow Gearslip under Settings > Notifications > Notification access on the phone.", + stringResource(R.string.notif_off), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) - GsButton(onClick = onOpen) { Text("Open") } + GsButton(onClick = onOpen) { Text(stringResource(R.string.guide_open)) } } } } @@ -160,10 +162,10 @@ private fun NotificationRow(n: CarNotification, onReply: (CarNotification) -> Un } } when { - n.replied -> Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary) + n.replied -> Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.notif_replied), tint = MaterialTheme.colorScheme.primary) n.reply != null -> GsButton(onClick = { VoiceReply.reply(n) }, tone = GsTone.Tonal) { ReplyGlyph(Modifier.size(20.dp)) - Text("Reply", Modifier.padding(start = 8.dp)) + Text(stringResource(R.string.crumb_reply), Modifier.padding(start = 8.dp)) } } } @@ -198,14 +200,14 @@ private fun ReplyPane(n: CarNotification, onDone: () -> Unit) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onDone) + GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), onDone) Surface( shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainerHighest, modifier = Modifier.weight(1f), ) { Text( - if (text.isEmpty()) "Reply to ${n.title.ifBlank { n.appLabel }}" else "$text|", + if (text.isEmpty()) stringResource(R.string.msg_reply_to, n.title.ifBlank { n.appLabel }) else "$text|", color = if (text.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt index b828c1b..b7c255c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement @@ -53,7 +55,7 @@ fun PhoneAppsScreen() { } Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) { - ScreenHeader("Screen sharing") + ScreenHeader(stringResource(R.string.app_screen_sharing)) Spacer(Modifier.height(12.dp)) if (!TouchRelayService.enabled) { TouchRelayWarning() @@ -94,11 +96,11 @@ private fun TouchRelayWarning() { ) { Icon(Icons.Filled.Warning, contentDescription = null) Text( - "Touch is off. Enable Gearslip under Settings > Accessibility on the phone.", + stringResource(R.string.phoneapps_touch_off), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) - GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") } + GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text(stringResource(R.string.guide_open)) } } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt index e691c23..070e13a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.* import android.Manifest import android.content.Context @@ -138,11 +140,11 @@ fun PhoneDialerScreen() { Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { // The tabs share the title's row, so the list and keypad get the height back. - ScreenHeader("Phone") { + ScreenHeader(stringResource(R.string.carset_phone)) { if (hasPermissions) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS } - DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD } + DialerTab(stringResource(R.string.dialer_contacts), tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS } + DialerTab(stringResource(R.string.dialer_keypad), tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD } } } } @@ -168,14 +170,13 @@ private fun MissingPermission(context: Context, onGranted: () -> Unit) { } Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - "Gearslip needs the Phone and Contacts permissions to dial from here. Allow them on " + - "the phone when you're parked.", + stringResource(R.string.dialer_needs_permission), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) GsButton(tone = GsTone.Tonal, onClick = { app.seb3thehacker.gearslip.PermissionActivity.ask(context, CALL_PERMISSIONS) - }) { Text("Ask on the phone") } + }) { Text(stringResource(R.string.dialer_ask)) } } } @@ -211,7 +212,7 @@ private fun Keypad(context: Context) { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - number.ifEmpty { "Enter a number" }, + number.ifEmpty { stringResource(R.string.dialer_enter_number) }, style = MaterialTheme.typography.headlineSmall, color = if (number.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -240,7 +241,7 @@ private fun Keypad(context: Context) { colors = gsColors(GsTone.Primary), enabled = number.isNotEmpty(), modifier = Modifier.weight(1f).fillMaxWidth(), - ) { Icon(Icons.Filled.Call, "Call") } + ) { Icon(Icons.Filled.Call, stringResource(R.string.dialer_call)) } } } } @@ -282,12 +283,12 @@ private fun ContactsList(context: Context) { Box(Modifier.weight(1f).fillMaxWidth()) { when { contacts.isEmpty() -> Text( - "No contacts with a phone number found.", + stringResource(R.string.dialer_no_contacts), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) filtered.isEmpty() -> Text( - "No contacts match \"$query\".", + stringResource(R.string.dialer_no_match, query), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -344,14 +345,14 @@ private fun ConfirmCallOverlay(contact: Contact, onConfirm: () -> Unit, onDismis verticalArrangement = Arrangement.spacedBy(20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("Call ${contact.name}?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.dialer_call_who, contact.name), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { GsButton(onClick = onDismiss, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) { - Text("Cancel", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.cancel), style = MaterialTheme.typography.titleMedium) } GsButton(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) { - Text("Call", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.dialer_call), style = MaterialTheme.typography.titleMedium) } } } @@ -368,7 +369,7 @@ private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> U modifier = Modifier.fillMaxWidth(), ) { Text( - query.ifEmpty { "Search contacts" }, + query.ifEmpty { stringResource(R.string.dialer_search) }, style = MaterialTheme.typography.bodyLarge, color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -406,7 +407,7 @@ private fun ContactRow(contact: Contact, onClick: () -> Unit) { Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } - Icon(Icons.Filled.Call, contentDescription = "Call", tint = MaterialTheme.colorScheme.primary) + Icon(Icons.Filled.Call, contentDescription = stringResource(R.string.dialer_call), tint = MaterialTheme.colorScheme.primary) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt index d0c7ad7..985ebb7 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import android.media.session.PlaybackState import android.support.v4.media.session.PlaybackStateCompat import androidx.compose.foundation.layout.Arrangement @@ -100,17 +102,17 @@ internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: val play = skip * 1.25f Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) { GsIconButton( - MediaIcons.Previous, "Previous", media::previous, + MediaIcons.Previous, stringResource(R.string.player_previous), media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = skip, iconSize = skip * 0.58f, shape = CircleShape, ) GsIconButton( if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, - if (now.playing) "Pause" else "Play", + stringResource(if (now.playing) R.string.player_pause else R.string.media_play), media::togglePlay, tone = GsTone.Primary, size = play, iconSize = play * 0.58f, shape = CircleShape, ) GsIconButton( - MediaIcons.Next, "Next", media::next, + MediaIcons.Next, stringResource(R.string.player_next), media::next, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = skip, iconSize = skip * 0.58f, shape = CircleShape, ) } @@ -147,7 +149,7 @@ internal fun PlayModeRow( } else { GsIconButton( if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, - if (liked == true) "Unlike" else "Like", + stringResource(if (liked == true) R.string.player_unlike else R.string.player_like), media::toggleLike, enabled = liked != null, tone = if (liked == true) GsTone.Primary else GsTone.Neutral, @@ -159,7 +161,7 @@ internal fun PlayModeRow( CustomActionButton(shuffleAction, media, size, toggle = true) } else { GsIconButton( - MediaIcons.Shuffle, if (shuffleOn) "Shuffle off" else "Shuffle on", + MediaIcons.Shuffle, stringResource(if (shuffleOn) R.string.player_shuffle_off else R.string.player_shuffle_on), media::toggleShuffle, enabled = shuffleKnown, tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral, @@ -172,7 +174,7 @@ internal fun PlayModeRow( } else { GsIconButton( if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat, - "Repeat", + stringResource(R.string.player_repeat), media::cycleRepeat, enabled = repeatKnown, tone = if (repeatOn) GsTone.Primary else GsTone.Neutral, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ScreenHeader.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ScreenHeader.kt index 6576a87..2e9550b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ScreenHeader.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ScreenHeader.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -29,7 +31,7 @@ fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Compos verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back) + GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back) Text( title, style = MaterialTheme.typography.headlineSmall, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt index 5f81673..8a9c53f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.* import android.graphics.Rect import androidx.compose.foundation.layout.widthIn @@ -257,7 +259,7 @@ private fun NavigationBars( private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) { if (info.isLoading) { ChromeSurface(modifier) { - Text("Finding a route…", Modifier.padding(horizontal = 18.dp, vertical = 16.dp), style = ChromeType.title) + Text(stringResource(R.string.nav_finding_route), Modifier.padding(horizontal = 18.dp, vertical = 16.dp), style = ChromeType.title) } return } @@ -312,7 +314,7 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) { Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text("Then", style = ChromeType.label, color = scheme.onSurfaceVariant) + Text(stringResource(R.string.nav_then), style = ChromeType.label, color = scheme.onSurfaceVariant) Spacer(Modifier.width(10.dp)) CarGlyph(next.maneuver?.icon, Modifier.size(28.dp)) val nextText = next.cue.text().ifEmpty { next.road.text() } @@ -374,7 +376,7 @@ private fun LaneTile(lane: Lane) { ) { Icon( Icons.Filled.KeyboardArrowUp, - contentDescription = if (recommended) "Recommended lane" else "Lane", + contentDescription = stringResource(if (recommended) R.string.nav_lane_recommended else R.string.nav_lane), tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()), @@ -427,7 +429,7 @@ private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Mo ) } val detail = listOfNotNull( - arrival.takeIf { it.isNotEmpty() }?.let { "Arrive $it" }, + arrival.takeIf { it.isNotEmpty() }?.let { stringResource(R.string.nav_arrive, it) }, distance.takeIf { it.isNotEmpty() }, ).joinToString(" • ") if (detail.isNotEmpty()) { @@ -442,7 +444,7 @@ private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Mo // pops the on-screen card, leaving the app still believing it is navigating. GsIconButton( Icons.Filled.Close, - "Stop navigating", + stringResource(R.string.nav_stop), { connection.stopNavigating() }, colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface), size = 52.dp, @@ -575,7 +577,7 @@ private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifie ) Box(Modifier.weight(1f, fill = false)) { if (template.isLoading) { - Text("Finding routes…", Modifier.padding(14.dp), style = ChromeType.title) + Text(stringResource(R.string.nav_finding_routes), Modifier.padding(14.dp), style = ChromeType.title) } else { ItemListColumn(template.itemList) } @@ -599,7 +601,7 @@ private fun ListPane( ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) { Column { HeaderBar(title, startAction) - if (loading) Text("Loading…", Modifier.padding(14.dp), style = ChromeType.title) + if (loading) Text(stringResource(R.string.media_loading), Modifier.padding(14.dp), style = ChromeType.title) else ItemListColumn(itemList) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt index ea52ddf..94c2c39 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.material.icons.filled.Close @@ -317,7 +319,7 @@ internal fun SearchField( Spacer(Modifier.width(12.dp)) } Text( - query.ifEmpty { hint.ifEmpty { "Search" } }, + query.ifEmpty { hint.ifEmpty { stringResource(R.string.template_search) } }, style = MaterialTheme.typography.titleMedium, color = if (query.isEmpty()) muted else MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -327,7 +329,7 @@ internal fun SearchField( if (onClear != null && query.isNotEmpty()) { GsIconButton( androidx.compose.material.icons.Icons.Filled.Close, - "Clear", + stringResource(R.string.storage_clear), onClear, colors = GsColors(androidx.compose.ui.graphics.Color.Transparent, muted), size = 42.dp, @@ -623,7 +625,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi } else { Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { Text( - "Nothing playing yet.", + stringResource(R.string.template_nothing_playing), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = androidx.compose.ui.text.style.TextAlign.Center, @@ -647,6 +649,6 @@ private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnSc @Composable private fun Loading() { Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) { - Text("Loading…", style = ChromeType.title) + Text(stringResource(R.string.media_loading), style = ChromeType.title) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt index 600c465..2a53e0b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -425,7 +427,7 @@ internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) { ) { if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart)) Text( - action.title.text().ifEmpty { "Start" }, + action.title.text().ifEmpty { stringResource(R.string.template_start) }, style = ChromeType.title, maxLines = 1, ) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateRows.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateRows.kt index 7e4aeb7..93c8480 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateRows.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateRows.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -300,7 +302,7 @@ private fun DetailRow(row: CarRow, placeName: String) { Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp)) Spacer(Modifier.width(6.dp)) - Text("Call", style = ChromeType.body) + Text(stringResource(R.string.dialer_call), style = ChromeType.body) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt index 976ec17..ecfba71 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt @@ -1,5 +1,9 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.annotation.StringRes +import app.seb3thehacker.gearslip.R import android.Manifest import android.content.Context import android.content.pm.PackageManager @@ -38,7 +42,7 @@ class WeatherData( sealed interface WeatherState { data object Loading : WeatherState - data class Unavailable(val reason: String) : WeatherState + data class Unavailable(@param:StringRes val reason: Int) : WeatherState data class Ready(val data: WeatherData) : WeatherState } @@ -78,7 +82,7 @@ object Weather { if (flow.value !is WeatherState.Ready) { val allowed = hasLocationPermission(context) flow.value = WeatherState.Unavailable( - if (allowed) "Waiting for a location fix." else "Allow location for Gearslip on the phone to see the weather.", + if (allowed) R.string.weather_waiting_fix else R.string.weather_allow_location, ) } return @@ -92,7 +96,7 @@ object Weather { // No place name: logs get shared in bug reports. GearslipLog.i("weather: loaded") } - else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.") + else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable(R.string.weather_unreachable) } // --- location ------------------------------------------------------------------------- @@ -197,6 +201,26 @@ object Weather { ) } + /** Open-Meteo's WMO weather codes, in words on the screen. [describe] is the English the assistant says. */ + @StringRes + fun conditionRes(code: Int): Int = when (code) { + 0 -> R.string.weather_clear + 1 -> R.string.weather_mostly_clear + 2 -> R.string.weather_partly_cloudy + 3 -> R.string.weather_overcast + 45, 48 -> R.string.weather_fog + in 51..57 -> R.string.weather_drizzle + in 61..67 -> R.string.weather_rain + in 71..77 -> R.string.weather_snow + in 80..82 -> R.string.weather_rain_showers + in 85..86 -> R.string.weather_snow_showers + in 95..99 -> R.string.weather_thunderstorm + else -> 0 + } + + @Composable + fun condition(code: Int): String = conditionRes(code).let { if (it == 0) "-" else stringResource(it) } + /** Open-Meteo's WMO weather codes, in words. */ fun describe(code: Int): String = when (code) { 0 -> "Clear" diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WeatherScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WeatherScreen.kt index 15ef3c0..0cb3eee 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WeatherScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WeatherScreen.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +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 @@ -41,11 +43,11 @@ fun WeatherScreen() { Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(20.dp), ) { - ScreenHeader("Weather") + ScreenHeader(stringResource(R.string.crumb_weather)) when (val s = state) { - WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium) + WeatherState.Loading -> Text(stringResource(R.string.weather_getting), style = MaterialTheme.typography.titleMedium) is WeatherState.Unavailable -> Text( - s.reason, style = MaterialTheme.typography.titleMedium, + stringResource(s.reason), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) is WeatherState.Ready -> Forecast(s.data) @@ -66,18 +68,18 @@ private fun Forecast(w: WeatherData) { WeatherIcon(w.code, w.isDay, size = 88.dp) Column { Text("${w.temp}${w.tempUnit}", fontSize = 56.sp, fontWeight = FontWeight.Bold) - Text(Weather.describe(w.code), style = MaterialTheme.typography.titleLarge) + Text(Weather.condition(w.code), style = MaterialTheme.typography.titleLarge) } Spacer(Modifier.weight(1f)) Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("Feels like ${w.feelsLike}${w.tempUnit}", style = MaterialTheme.typography.bodyLarge) - Text("Humidity ${w.humidity}%", style = MaterialTheme.typography.bodyLarge) - Text("Wind ${w.wind} ${w.windUnit}", style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.weather_feels_like, "${w.feelsLike}${w.tempUnit}"), style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.weather_humidity, w.humidity), style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.weather_wind, "${w.wind} ${w.windUnit}"), style = MaterialTheme.typography.bodyLarge) } } } - Text("Next hours", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Text(stringResource(R.string.weather_next_hours), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { items(w.hours) { h -> Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) { @@ -101,7 +103,7 @@ private fun Forecast(w: WeatherData) { } } - Text("This week", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Text(stringResource(R.string.weather_this_week), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { w.days.forEachIndexed { i, d -> Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) { @@ -111,11 +113,11 @@ private fun Forecast(w: WeatherData) { horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - if (i == 0) "Today" else SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(d.date)), + if (i == 0) stringResource(R.string.weather_today) else SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(d.date)), Modifier.width(120.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, ) WeatherIcon(d.code, true, size = 28.dp) - Text(Weather.describe(d.code), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) + Text(Weather.condition(d.code), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) if (d.rainChance > 0) Text("${d.rainChance}%", color = MaterialTheme.colorScheme.primary) Text("${d.low}° / ${d.high}°", style = MaterialTheme.typography.bodyLarge) } @@ -123,7 +125,7 @@ private fun Forecast(w: WeatherData) { } } Text( - "Weather data by Open-Meteo.com (CC BY 4.0)", + stringResource(R.string.weather_credit), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt index 2fe8b5d..3074f46 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import android.os.Handler import android.os.Looper import android.view.View @@ -95,19 +97,19 @@ fun WebApp(content: String) { Column(Modifier.fillMaxSize()) { // Back steps through the page's history first (the interceptor above), then leaves. if (!fullscreen) { - ScreenHeader(pageTitle.ifEmpty { "Browser" }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, "Search or type an address", { + ScreenHeader(pageTitle.ifEmpty { stringResource(R.string.app_browser) }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, stringResource(R.string.web_search), { fieldText = null address = "" }) if (canGoForward) { - GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, "Forward", { web?.goForward() }) + GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, stringResource(R.string.web_forward), { web?.goForward() }) } - GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, "Reload", { web?.reload() }) - GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, "Start page", { + GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, stringResource(R.string.web_reload), { web?.reload() }) + GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, stringResource(R.string.web_start_page), { web?.let { view -> loadContent(view, content) } }) - GsIconButton(MediaIcons.Fullscreen, "Full screen", CarFullscreen::enter) + GsIconButton(MediaIcons.Fullscreen, stringResource(R.string.web_fullscreen), CarFullscreen::enter) } } Box(Modifier.weight(1f).fillMaxSize()) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt index f25922c..5675777 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt @@ -72,7 +72,7 @@ private fun navBarChoices() = listOf( NavBarMode.HIDE to stringResource(R.string.choice_nav_hide), ) @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 } +private fun openChoices() = listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.all.filter { it.id != "settings" }.map { it.id to stringResource(it.labelRes) } /** * The car's settings on the phone's home screen: one card per group with a summary of where it diff --git a/gearslip/src/main/res/values/strings.xml b/gearslip/src/main/res/values/strings.xml index 6e96201..47c2306 100644 --- a/gearslip/src/main/res/values/strings.xml +++ b/gearslip/src/main/res/values/strings.xml @@ -432,4 +432,412 @@ %1$s (this version) + + + Theme + Imperial (mi, ft, °F) + Metric (km, m, °C) + Screen size (next connection) + Fit to the screen + Off + On + Lyrics from + More sources + Hide + Show + Phone + Vehicle + Video + Version + Build + What\'s new after updates + Show the list of changes once, the first time the car connects after an update. + DuckDuckGo (default) - tap to set a different page + Shown by the Browser app on the launcher. Applies on the next connection. + Screen fit + Top + Bottom + Save for this vehicle + No vehicle profile matched + + + USB sends the phone\'s sound through Gearslip. Android asks to share your screen each drive, and a few apps block it. Bluetooth plays as the phone normally would, and Android never asks. + Gearslip reads each turn aloud through the car\'s speakers, and the music dips while it speaks. Turn it off if your map app\'s own voice already plays in the car. + Listens through the phone\'s own speech service instead of Gearslip\'s. On most phones that\'s Google\'s, which may send your voice to Google. GrapheneOS has none, so Gearslip keeps using its own. + Adds a Vehicle data tile: a live, raw dump of the head unit\'s sensor channel. Field labels (speed, RPM, fuel...) are carried over from other reverse-engineering projects and unverified against this car - treat them as a guess, not a fact, until you\'ve watched one change for yourself. + Lowers the picture\'s quality when the car, or its Wi-Fi, can\'t keep up, and raises it again once it can. Takes effect on the next connection. + Usable height %1$dpx of 480 + Saved to %1$s + Could not save + %1$dpx + + + Open on phone + Now playing + Up next + Browse + Type a song, artist, album, or playlist. + Looking for lyrics… + Nothing playing + Press play to pick up where you left off, or find something in Browse. + + + %1$s keeps its library to itself. + Gearslip can still control it once it has Notification access on the phone. Allow it under Settings > Notifications > Notification access. + %1$s isn\'t running. + %1$s plays through Gearslip\'s own player, but only while it\'s open on the phone. Open it once and it keeps playing here in the car. + Open %1$s + %1$s would not let Gearslip browse it. + Some media apps only accept Google\'s own host. + Connecting to %1$s… + Search %1$s + %1$s can\'t list results. Press search, and %1$s picks what to play. + %1$s can\'t search from the car. + Searching %1$s… + %1$s found nothing for \"%2$s\". + %1$s didn\'t play anything for \"%2$s\". + Searching %1$s for \"%2$s\"… + Nothing playing. + No lyrics found for this track. + + + Up + Loading… + Start music in the app on your phone. Play, pause, skip and seek work from here. + Could not load this list. + Nothing here. + Nothing queued. + Play + + + Nothing playing - not every app tells Gearslip what comes next. + Only what\'s already playing - not every app tells Gearslip what comes next. + + + Previous + Next + Repeat + + + Pause + Unlike + Like + Shuffle off + Shuffle on + + + Choose another app + No mapping apps found. + Install a navigation app that supports Android Auto and it will start here on its own. + Map stopped + It stays closed until you open it again. + Reopen the map + Car apps + Back to the nav bar + Show the player + Hide lyrics + + + Starting %1$s… + Starting the map… + %1$s would not start. + The map app would not start. + + + Home + Media + Apps + Dashboard + Weather + Reply + + + Browser + Screen sharing + Exit + Unpin from nav bar + Nav bar is full (%1$d pinned) + Pin to nav bar + Close app + Move left + Move right + Reset app order + + + Dismiss + Read + Stop + No + Send now + Type + + + + %1$s · %2$d new + %1$s · %2$d new + + Reply to %1$s + Listening + Sending to %1$s + Sent to %1$s + Couldn\'t reply + + + Pairing didn\'t finish. Pair from the car\'s Bluetooth menu instead. + Pairing didn\'t start. Pair from the car\'s Bluetooth menu instead. + Paired. Calls now play through the car. + + + Don\'t show again + OK + Location is off + Pair with this car? + Pair + Confirm on your phone + Check the code on your phone and tap Pair. The car may ask too. + + + Music plays over Bluetooth, but your phone isn\'t connected to the car. Connect it from the car\'s Bluetooth menu or your phone\'s. + Map apps can\'t show where you are or give directions. Turn on location in your phone\'s quick settings when you\'re parked. + Calls and music play through the car\'s speakers once your phone and the car are paired. Your phone then asks you to confirm a code. + Calls play through the car\'s speakers once your phone and the car are paired. Your phone then asks you to confirm a code. + + + Waiting for a location fix. + Allow location for Gearslip on the phone to see the weather. + Could not reach the weather service. + Clear + Mostly clear + Partly cloudy + Overcast + Fog + Drizzle + Rain + Snow + Rain showers + Snow showers + Thunderstorm + + + Getting the weather… + Next hours + This week + Today + Weather data by Open-Meteo.com (CC BY 4.0) + + + Feels like %1$s + Humidity %1$d%% + Wind %1$s + + + Today + Getting weather… + Coming up + Loading calendar… + Nothing in the next week. + + + All day + + + Contacts + Keypad + Ask on the phone + Enter a number + Call + No contacts with a phone number found. + Search contacts + + + Gearslip needs the Phone and Contacts permissions to dial from here. Allow them on the phone when you\'re parked. + No contacts match \"%1$s\". + Call %1$s? + + + Exit full screen + Drive safely + I understand + + + Gearslip is unofficial software: it is not made or reviewed by any carmaker, and it can fail without warning. + Never interact with this screen while driving. Set the route or the song before you go, then keep your attention on the road. + This app + %1$s doesn\'t work with Gearslip yet. + It didn\'t start. Some apps only run on Google\'s Android Auto. + + + On a call + End call + + + Unmute + Mute + Close the side panel + Move the player beside the map + + + Search or type an address + Forward + Reload + Start page + Full screen + + + Notifications + Clear all + Nothing yet. New notifications from the phone will show up here. + Notifications are off. Allow Gearslip under Settings > Notifications > Notification access on the phone. + Replied + + + Mark read + + + Messages + No recent messages. New %1$s messages appear here as they arrive on the phone. + + %1$d earlier + %1$d earlier + + Hide quick replies + Quick reply + You: + %1$s: + + + Finding a route… + Then + Stop navigating + Finding routes… + + + Recommended lane + Lane + Arrive %1$s + + + Nothing playing yet. + + + Search + + + Start + + + Incoming call + Decline + Answer + Already ringing out loud - answer here, or let it ring. + + + Touch is off. Enable Gearslip under Settings > Accessibility on the phone. + + + Show after updates + Got it + + + What\'s new in %1$s + + + space + Hide keyboard + Submit + + + Doesn\'t work with Gearslip + + + Car app + Close %1$s + Close the app + + + Turn slightly left + Turn slightly left onto %1$s + Turn slightly right + Turn slightly right onto %1$s + Turn left + Turn left onto %1$s + Turn right + Turn right onto %1$s + Turn sharply left + Turn sharply left onto %1$s + Turn sharply right + Turn sharply right onto %1$s + Make a U-turn + Keep left + Keep left toward %1$s + Keep right + Keep right toward %1$s + Keep left at the fork + Keep left at the fork toward %1$s + Keep right at the fork + Keep right at the fork toward %1$s + Take the ramp on the left + Take the ramp on the left onto %1$s + Take the ramp on the right + Take the ramp on the right onto %1$s + Take the exit on the left + Take the exit on the left toward %1$s + Take the exit on the right + Take the exit on the right toward %1$s + Merge left + Merge left onto %1$s + Merge right + Merge right onto %1$s + Merge + Merge onto %1$s + Go through the roundabout + Go through the roundabout onto %1$s + Enter the roundabout + Exit the roundabout + Exit the roundabout onto %1$s + Continue straight + Continue straight onto %1$s + Continue + Continue onto %1$s + Take the ferry + Take the train + Your destination is ahead + Your destination is on the left + Your destination is on the right + At the roundabout, take the %1$s exit + At the roundabout, take the %1$s exit onto %2$s + first + second + third + fourth + fifth + sixth + seventh + eighth + In %1$s, %2$s + + %1$s kilometer + %1$s kilometers + + %1$s kilometers + a quarter mile + half a mile + three quarters of a mile + + %1$s mile + %1$s miles + + %1$s miles + + %1$s foot + %1$s feet + + + %1$s yard + %1$s yards + + + %1$d meter + %1$d meters +