taller car keyboard, type from the phone, real media search

A box on the phone types into the car's text field. Media apps with library search show a list of results to pick from, searching a second after you stop typing.
This commit is contained in:
Seb3thehacker committed 2026-10-05 01:21:16 -07:00
1 parent 7d197bf021
commit 355eb8bffd
6 files changed
+419 -73

No files matched your search

@@ -27,6 +27,13 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceIn
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.LaunchedEffect
/**
* Whether a [CarKeyboard] is on screen right now, so [CarUi] can give it the row of height the
@@ -72,64 +79,77 @@ fun CarKeyboard(
onDispose { CarKeyboardVisibility.state.value = false }
}
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
// The same field on the phone, for typing on its own keyboard: see [PhoneTyping].
val latestChange by rememberUpdatedState(onTextChange)
val latestSubmit by rememberUpdatedState(onSubmit)
val latestDismiss by rememberUpdatedState(onDismiss)
val phoneField = remember {
PhoneTyping.open(text, { latestChange(it) }, { latestSubmit() }, onDismiss?.let { { latestDismiss?.invoke() } })
}
DisposableEffect(phoneField) { onDispose { PhoneTyping.close(phoneField) } }
LaunchedEffect(text) { PhoneTyping.carText(phoneField, text) }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.fillMaxWidth(),
) {
Column(
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp)
CompositionLocalProvider(LocalKeyHeight provides keyHeight) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.fillMaxWidth(),
) {
rows.dropLast(1).forEach { row ->
Column(
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
rows.dropLast(1).forEach { row ->
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
row.forEach { key ->
val label = if (shifted && !symbols) key.uppercase() else key
Key(label, Modifier.weight(1f)) {
onTextChange(text + label)
shifted = false
}
}
}
}
// The bottom letter row: shift and backspace bookend it, exactly where a physical
// keyboard puts them relative to the letter block, not down beside the space bar.
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
row.forEach { key ->
if (!symbols) {
Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.6f)) { shifted = !shifted }
}
rows.last().forEach { key ->
val label = if (shifted && !symbols) key.uppercase() else key
Key(label, Modifier.weight(1f)) {
onTextChange(text + label)
shifted = false
}
}
}
}
// The bottom letter row: shift and backspace bookend it, exactly where a physical
// keyboard puts them relative to the letter block, not down beside the space bar.
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (!symbols) {
Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.6f)) { shifted = !shifted }
}
rows.last().forEach { key ->
val label = if (shifted && !symbols) key.uppercase() else key
Key(label, Modifier.weight(1f)) {
onTextChange(text + label)
shifted = false
Key("⌫", Modifier.weight(1.6f)) {
if (text.isNotEmpty()) onTextChange(text.dropLast(1))
}
}
Key("⌫", Modifier.weight(1.6f)) {
if (text.isNotEmpty()) onTextChange(text.dropLast(1))
}
}
// Control row: no backspace here - it stays with the letters above, not next to the
// key most likely to be hit right after it by mistake. Dismiss (when offered) is the
// last key, bottom-right corner of the keyboard.
Row(
Modifier.fillMaxWidth(),
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 ") }
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
if (onDismiss != null) {
DismissKey(onDismiss, Modifier.weight(1.4f))
// Control row: no backspace here - it stays with the letters above, not next to the
// key most likely to be hit right after it by mistake. Dismiss (when offered) is the
// last key, bottom-right corner of the keyboard.
Row(
Modifier.fillMaxWidth(),
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 ") }
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
if (onDismiss != null) {
DismissKey(onDismiss, Modifier.weight(1.4f))
}
}
}
}
@@ -140,7 +160,7 @@ fun CarKeyboard(
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
GsIconBox(
onClick = onClick,
modifier = modifier.height(KEY_HEIGHT),
modifier = modifier.height(LocalKeyHeight.current),
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
shape = RoundedCornerShape(8.dp),
) {
@@ -163,7 +183,7 @@ private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Uni
private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
GsIconBox(
onClick = onClick,
modifier = modifier.height(KEY_HEIGHT),
modifier = modifier.height(LocalKeyHeight.current),
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurfaceVariant),
shape = RoundedCornerShape(8.dp),
) {
@@ -181,7 +201,7 @@ private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) {
GsIconBox(
onClick = onClick,
modifier = modifier.height(KEY_HEIGHT),
modifier = modifier.height(LocalKeyHeight.current),
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
shape = RoundedCornerShape(8.dp),
) {
@@ -199,10 +219,12 @@ private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?,
}
/**
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few
* results all fit a 480px-tall screen - which is the real constraint here, not the key size.
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few results
* still fit. A 480-tall screen sets the floor; a taller one (LIVI's 1280x720) gets bigger keys.
*/
private val KEY_HEIGHT = 34.dp
private fun keyHeight(screenHeightDp: Int): Dp = (screenHeightDp * 0.075f).dp.coerceIn(36.dp, 52.dp)
private val LocalKeyHeight = compositionLocalOf { 36.dp }
private val LETTER_ROWS = listOf(
"qwertyuiop".map { it.toString() },
@@ -70,6 +70,9 @@ import app.seb3thehacker.gearslip.media.MediaEntry
import app.seb3thehacker.gearslip.media.NowPlaying
import app.seb3thehacker.gearslip.media.QueueTrack
import android.media.session.PlaybackState
import kotlinx.coroutines.delay
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
/**
* A media app on the car screen: what it is playing and its controls on the left, the tree it
@@ -134,7 +137,17 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
if (searching) {
SearchPanel(
appLabel = app.label,
onSearch = { query ->
media = media,
canPickForYou = now.canSearch,
onPlayed = {
searching = false
tab = Tab.NOW_PLAYING
},
onOpened = {
searching = false
tab = Tab.BROWSE
},
onPickForYou = { query ->
media.search(query)
searching = false
tab = Tab.NOW_PLAYING
@@ -167,7 +180,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
if (now.canSearch) {
if (now.canSearch || media.canSearchLibrary) {
GsIconButton(
Icons.Filled.Search,
"Search ${app.label}",
@@ -225,9 +238,36 @@ private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS }
* Types a search for the app to play. The app picks the result and plays it, the way it would
* for "play X" from a voice assistant, so there's no list to choose from.
*/
/**
* Search inside the media app. It asks the app's library for matches half a second after the
* driver stops typing and lists them, so the driver picks exactly what plays: a song plays, an
* album or artist opens in Browse. The search key hides the keyboard to show more results; the
* field brings it back. An app with no library search can still be told to play its own best
* match, which is all the panel could do before.
*/
@Composable
private fun SearchPanel(appLabel: String, onSearch: (String) -> Unit, onClose: () -> Unit) {
private fun SearchPanel(
appLabel: String,
media: CarMedia,
canPickForYou: Boolean,
onPlayed: () -> Unit,
onOpened: () -> Unit,
onPickForYou: (String) -> Unit,
onClose: () -> Unit,
) {
var query by remember { mutableStateOf("") }
var typing by remember { mutableStateOf(true) }
val results by media.results.collectAsStateWithLifecycle()
LaunchedEffect(query) {
delay(SEARCH_PAUSE_MS)
media.searchLibrary(query)
}
DisposableEffect(Unit) { onDispose { media.clearResults() } }
val trimmed = query.trim()
val current = results?.takeIf { it.query == trimmed }
val unsupported = current?.supported == false
Column(Modifier.fillMaxSize()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
@@ -235,25 +275,72 @@ private fun SearchPanel(appLabel: String, onSearch: (String) -> Unit, onClose: (
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
GsIconButton(Icons.Filled.ArrowBack, "Back", onClose, size = ICON_BUTTON_SIZE)
SearchField(query, "Search $appLabel", Modifier.weight(1f), onClear = { query = "" })
}
Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 24.dp), contentAlignment = Alignment.Center) {
Text(
"Type a song, artist, album, or playlist. $appLabel picks what to play.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
SearchField(
query,
"Search $appLabel",
Modifier.weight(1f).clickable { typing = true },
onClear = {
query = ""
typing = true
},
)
}
CarKeyboard(
text = query,
onTextChange = { query = it },
onSubmit = { if (query.isNotBlank()) onSearch(query.trim()) },
onDismiss = onClose,
Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) {
val entries = current?.entries
when {
trimmed.isEmpty() -> SearchNote("Type a song, artist, album, or playlist.")
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\".")
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 8.dp)) {
items(entries) { entry ->
EntryRow(entry, media) {
if (entry.browsable) {
media.select(entry)
onOpened()
} else {
media.playAll(entry)
onPlayed()
}
}
}
}
}
}
if (typing) {
CarKeyboard(
text = query,
onTextChange = { query = it },
onSubmit = {
when {
trimmed.isEmpty() -> Unit
unsupported && canPickForYou -> onPickForYou(trimmed)
else -> typing = false
}
},
onDismiss = { if (trimmed.isEmpty()) onClose() else typing = false },
)
}
}
}
@Composable
private fun SearchNote(text: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
/** How long typing has to pause before the library is searched. */
private const val SEARCH_PAUSE_MS = 1_000L
/** A line under the tabs while the app works on a search, and if it plays nothing for it. */
@Composable
private fun SearchStatus(search: CarMedia.Search, appLabel: String, onDismiss: () -> Unit) {
@@ -572,13 +659,13 @@ private fun QueueRow(track: QueueTrack, media: CarMedia) {
}
@Composable
private fun EntryRow(entry: MediaEntry, media: CarMedia) {
private fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
val context = LocalContext.current
val icon by produceState(entry.iconBitmap, entry.iconUri) {
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
}
Surface(
onClick = { media.select(entry) },
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
@@ -0,0 +1,60 @@
package app.seb3thehacker.gearslip.car
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* The text field the car keyboard is typing into, shared with the phone so a passenger can type
* on the phone's own keyboard instead. Whichever [CarKeyboard] is on screen registers here; the
* phone app shows the same text while one is open, and its edits go back through the car field's
* own callbacks, so the car screen can't tell which keyboard typed.
*/
object PhoneTyping {
class Field internal constructor(
val id: Int,
val text: String,
internal val onTextChange: (String) -> Unit,
internal val onSubmit: () -> Unit,
internal val onDismiss: (() -> Unit)?,
)
private val fieldFlow = MutableStateFlow<Field?>(null)
/** The car's open field, or null when no car keyboard is showing. */
val field: StateFlow<Field?> = fieldFlow.asStateFlow()
private var nextId = 0
internal fun open(text: String, onTextChange: (String) -> Unit, onSubmit: () -> Unit, onDismiss: (() -> Unit)?): Int {
val id = ++nextId
fieldFlow.value = Field(id, text, onTextChange, onSubmit, onDismiss)
return id
}
/** The car field's text changed, from either keyboard. */
internal fun carText(id: Int, text: String) {
val f = fieldFlow.value ?: return
if (f.id == id && f.text != text) fieldFlow.value = Field(id, text, f.onTextChange, f.onSubmit, f.onDismiss)
}
internal fun close(id: Int) {
if (fieldFlow.value?.id == id) fieldFlow.value = null
}
/** From the phone: typed text goes to the car field as if typed there. */
fun type(text: String) {
fieldFlow.value?.onTextChange?.invoke(text)
}
fun submit() {
fieldFlow.value?.onSubmit?.invoke()
}
/** Closes the car keyboard where it can be closed; elsewhere only the phone's field goes. */
fun dismiss() {
val f = fieldFlow.value ?: return
f.onDismiss?.invoke() ?: close(f.id)
}
}
@@ -6,6 +6,7 @@ import android.content.Intent
import android.graphics.Bitmap
import android.media.MediaMetadata
import android.media.Rating
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
@@ -170,6 +171,17 @@ class CarMedia(private val context: Context) {
/** A typed search on its way to the app; [Search.ignored] once it has played nothing for it. */
class Search(val query: String, val ignored: Boolean = false)
/**
* What the app's own library search found for [query]. [entries] is null while it looks.
* [supported] is false when the app has no library search, so the panel can fall back to
* "play the best match" (see [search]).
*/
class Results(val query: String, val entries: List<MediaEntry>? = null, val supported: Boolean = true)
private val _results = MutableStateFlow<Results?>(null)
val results: StateFlow<Results?> = _results.asStateFlow()
private var resultsQuery: String? = null
private val _search = MutableStateFlow<Search?>(null)
val search: StateFlow<Search?> = _search.asStateFlow()
private var searchBefore: NowPlaying? = null
@@ -286,6 +298,7 @@ class CarMedia(private val context: Context) {
private fun onConnected(app: MediaApp) {
val mb = browser ?: return
searchComponent = app.component
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
attach(MediaController(context, mb.sessionToken))
rootId = mb.root
@@ -423,11 +436,102 @@ class CarMedia(private val context: Context) {
main.removeCallbacks(searchTimeout)
searchBefore = _now.value
_search.value = Search(query)
GearslipLog.i("media: -> playFromSearch(\"$query\")")
GearslipLog.i("media: -> playFromSearch")
c.transportControls.playFromSearch(query, Bundle())
main.postDelayed(searchTimeout, SEARCH_WAIT_MS)
}
/**
* Asks the app's library for matches to [query], to list for the driver to pick from. Unlike
* [search], nothing plays until one is chosen, so a wrong guess costs nothing. Metrolist and
* ViVi answer with songs from the library first, then YouTube Music.
*/
fun searchLibrary(query: String) {
val q = query.trim()
if (q.isEmpty()) {
clearResults()
return
}
val component = searchComponent
if (component == null) {
_results.value = Results(q, emptyList(), supported = false)
return
}
resultsQuery = q
_results.value = Results(q)
val sb = searchBrowser
if (sb != null && sb.isConnected) {
runSearch(sb, q)
return
}
if (sb != null) return // still connecting; it searches for [resultsQuery] once connected
// The framework MediaBrowser has no search; the support library's does, over the same
// service. Opened on first use only, so apps nobody searches never see a second client.
lateinit var created: MediaBrowserCompat
created = MediaBrowserCompat(context, component, object : MediaBrowserCompat.ConnectionCallback() {
override fun onConnected() {
resultsQuery?.let { runSearch(created, it) }
}
override fun onConnectionFailed() {
GearslipLog.w("media: no library connection for search")
searchBrowser = null
resultsQuery?.let { _results.value = Results(it, emptyList(), supported = false) }
}
}, null)
searchBrowser = created
runCatching { created.connect() }.onFailure {
searchBrowser = null
_results.value = Results(q, emptyList(), supported = false)
}
}
private fun runSearch(sb: MediaBrowserCompat, q: String) {
runCatching {
sb.search(q, Bundle(), object : MediaBrowserCompat.SearchCallback() {
override fun onSearchResult(query: String, extras: Bundle?, items: List<MediaBrowserCompat.MediaItem>) {
if (query != resultsQuery) return
GearslipLog.i("media: library search found ${items.size}")
_results.value = Results(query, items.map(::entryOfCompat))
}
override fun onError(query: String, extras: Bundle?) {
if (query != resultsQuery) return
GearslipLog.i("media: the app has no library search")
_results.value = Results(query, emptyList(), supported = false)
}
})
}.onFailure {
GearslipLog.w("media: library search failed: ${it.message}")
_results.value = Results(q, emptyList(), supported = false)
}
}
private fun entryOfCompat(item: MediaBrowserCompat.MediaItem): MediaEntry {
val d = item.description
return MediaEntry(
id = item.mediaId.orEmpty(),
title = d.title?.toString().orEmpty(),
subtitle = d.subtitle?.toString().orEmpty(),
iconUri = d.iconUri,
iconBitmap = d.iconBitmap,
browsable = item.isBrowsable,
playable = item.isPlayable,
)
}
/** The connected app's service, for the search connection; null when nothing is connected. */
private var searchComponent: android.content.ComponentName? = null
private var searchBrowser: MediaBrowserCompat? = null
fun clearResults() {
resultsQuery = null
_results.value = null
}
/** Whether there's a library to search through, the better of the two searches. */
val canSearchLibrary: Boolean get() = browser?.isConnected == true && searchComponent != null
fun dismissSearch() {
main.removeCallbacks(searchTimeout)
main.removeCallbacks(clearSearch)
@@ -437,7 +541,7 @@ class CarMedia(private val context: Context) {
private val searchTimeout = Runnable {
val pending = _search.value ?: return@Runnable
if (pending.ignored) return@Runnable
GearslipLog.w("media: nothing new played for \"${pending.query}\"")
GearslipLog.w("media: nothing new played after a search")
_search.value = Search(pending.query, ignored = true)
main.postDelayed(clearSearch, SEARCH_NOTICE_MS)
}
@@ -451,7 +555,7 @@ class CarMedia(private val context: Context) {
val now = _now.value
if (pending.ignored || !now.playing) return
if (now.title != before.title || !before.playing) {
GearslipLog.i("media: search played \"${now.title}\"")
GearslipLog.i("media: search started playback")
dismissSearch()
}
}
@@ -486,6 +590,10 @@ class CarMedia(private val context: Context) {
fun disconnect() {
connecting = null
runCatching { searchBrowser?.disconnect() }
searchBrowser = null
searchComponent = null
clearResults()
main.removeCallbacksAndMessages(null)
runCatching { compat?.unregisterCallback(compatCallback) }
compat = null
@@ -95,21 +95,21 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
onOpenSettings = { screen = Screen.SETTINGS },
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
onOpenHelp = { screen = Screen.HELP },
onOpenWhatsNew = { screen = Screen.WHATS_NEW },
onDisconnect = onDisconnect,
)
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = Screen.HOME })
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = Screen.SETTINGS })
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
Screen.SETTINGS -> SettingsScreen(
onBack = { screen = Screen.HOME },
onOpenRecommendedApps = { screen = Screen.RECOMMENDED_APPS },
onOpenWhatsNew = { screen = Screen.WHATS_NEW },
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
)
Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS })
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
Screen.HELP -> HelpScreen(
onBack = { screen = Screen.HOME },
onRequestCallScreening = onRequestCallScreening,
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
)
Screen.CERT_SETUP -> CertSetupScreen(
onDone = {
@@ -122,6 +122,8 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
)
}
}
// A car keyboard is open: offer the phone's own, for a passenger.
if (screen != Screen.CAR_PREVIEW) PhoneTypingBar()
}
CompatWarning()
@@ -0,0 +1,67 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.car.PhoneTyping
/**
* While the car screen shows its keyboard, the phone shows the same field with its own keyboard
* up, so a passenger can type comfortably. Each keystroke lands on the car screen at once.
*/
@Composable
fun PhoneTypingBar(modifier: Modifier = Modifier) {
val field by PhoneTyping.field.collectAsState()
val open = field ?: return
var value by remember(open.id) { mutableStateOf(TextFieldValue(open.text, TextRange(open.text.length))) }
// The car keyboard typed too: take its text, cursor at the end.
if (open.text != value.text) value = TextFieldValue(open.text, TextRange(open.text.length))
val focus = remember { FocusRequester() }
LaunchedEffect(open.id) { runCatching { focus.requestFocus() } }
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.fillMaxWidth().imePadding()) {
Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = value,
onValueChange = {
value = it
PhoneTyping.type(it.text)
},
label = { Text("Type for the car screen") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { PhoneTyping.submit() }),
modifier = Modifier.weight(1f).focusRequester(focus),
)
IconButton(onClick = PhoneTyping::dismiss) {
Icon(Icons.Filled.Close, contentDescription = "Close the car keyboard")
}
}
}
}