mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user