car: the car screen's text and the spoken directions come from strings.xml, ready for translation; voice commands stay English

This commit is contained in:
Seb3thehacker committed 2026-10-26 13:04:29 -07:00
1 parent 0fd7568536
commit 3fbabfa0ed
34 files changed
+900 -393

No files matched your search

@@ -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,
)
}
}
@@ -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,
)
@@ -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,
@@ -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<CarApp> = 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()
},
)
@@ -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() {
@@ -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
}
@@ -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)
}
@@ -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),
)
}
}
@@ -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<AppTile> {
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))
}
}
@@ -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<Entry>, 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,
)
@@ -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. */
@@ -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<ScreenSize?>(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<String>, 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,
@@ -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)) }
}
}
@@ -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)
}
}
}
@@ -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,
)
}
@@ -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<QueueTrack>, 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)
}
@@ -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,
)
@@ -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 -> ""
}
@@ -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(
@@ -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<Entry> = 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<String, RunningSlot>)
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<String, RunningSlot>)
) {
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<String, RunningSlot>)
},
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) }
}
}
@@ -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,
@@ -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)) }
}
}
}
@@ -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)
}
}
}
@@ -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,
@@ -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,
@@ -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)
}
}
@@ -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)
}
}
@@ -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,
)
@@ -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)
}
}
}
@@ -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"
@@ -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,
)
}
@@ -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()) {
@@ -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