From 239be1256bf1f3ea3a9a9335e62089549e77388e Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Fri, 9 Oct 2026 15:26:48 -0700 Subject: [PATCH] place pages: name on top, actions as buttons, call a place's number Panes shaped like a place page (MAPS.ME's) show the name and kind first, short tappable rows as buttons, and the details compact below. A phone number gets a Call button that asks first with the dialer's confirmation. Other panes draw as before. --- .../app/seb3thehacker/gearslip/car/CarUi.kt | 1 + .../gearslip/car/PhoneDialerScreen.kt | 26 ++++ .../gearslip/car/TemplateParts.kt | 128 +++++++++++++++++- 3 files changed, 154 insertions(+), 1 deletion(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index 6bdf7c0..c21f6e2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -227,6 +227,7 @@ fun CarUi() { } } CarToastOverlay(Modifier.align(Alignment.BottomCenter)) + CallPromptOverlay() PopupTimer() // The dashboard is already listing them, so it needs no popup. NotificationPopup( diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt index cb21b59..a7ba655 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -89,6 +90,31 @@ internal fun placeCall(context: Context, number: String) { } } +/** A call another car screen wants to place - a number on a place page - waiting on the driver. */ +internal object CallPrompt { + val pending = kotlinx.coroutines.flow.MutableStateFlow(null) + + fun ask(name: String, number: String) { + pending.value = Contact(name, number) + } +} + +/** The dialer's own confirmation, drawn over the whole car screen for [CallPrompt]. */ +@Composable +internal fun CallPromptOverlay() { + val context = LocalContext.current + val contact by CallPrompt.pending.collectAsState() + val c = contact ?: return + ConfirmCallOverlay( + c, + onConfirm = { + CallPrompt.pending.value = null + placeCall(context, c.number) + }, + onDismiss = { CallPrompt.pending.value = null }, + ) +} + private const val TAB_CONTACTS = 0 private const val TAB_KEYPAD = 1 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt index 91303ca..cee1973 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Warning @@ -591,16 +592,141 @@ internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boo // Some apps pad a pane with a row of blank strings; drawn, it is just a gap. val rows = pane?.rows.orEmpty().filterNot { it.isBlank() } val actions = pane?.actions.orEmpty().filter { it.isDrawable() } + val card = PlaceCard.of(rows) Column(modifier) { - rows.forEach { RowItem(it, large = true) } + card.heading?.let { PlaceHeading(it) } + if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp)) if (autoStart && actions.size == 1) { AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) } else { ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true) } + if (card.heading == null && card.buttons.isEmpty()) { + card.details.forEach { RowItem(it, large = true) } + } else { + card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) } + } } } +/** + * A pane split the way a place page reads: a name on top, the things to do with it as buttons, + * then the facts. Apps send all three as plain rows, so the split is worked out from the rows' + * shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no + * heading and no buttons, and draws exactly as a plain list of rows. + */ +private class PlaceCard(val heading: CarRow?, val buttons: List, val details: List) { + companion object { + fun of(rows: List): PlaceCard { + // A short tappable row with an icon and nothing under it is a button in all but name: + // MAPS.ME's "Let's go" and "Save". + val buttons = rows.filter { + it.onClickDelegate != null && it.toggle == null && it.image != null && + it.texts.orEmpty().isEmpty() && it.title.text().length <= 24 + }.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty() + // A first row that can't be tapped and has no icon is the place's name and kind. + val heading = rows.firstOrNull()?.takeIf { + buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null + } + return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet()) + } + } +} + +@Composable +private fun PlaceHeading(row: CarRow) { + Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) { + Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis) + row.texts.orEmpty().take(2).forEach { + Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */ +@Composable +private fun RowButtons(rows: List, modifier: Modifier) { + val scheme = MaterialTheme.colorScheme + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + rows.forEachIndexed { i, row -> + GsIconBox( + onClick = { row.onClickDelegate.click("row") }, + modifier = Modifier.weight(1f), + colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface), + enabled = row.isEnabled, + shape = RoundedCornerShape(24.dp), + ) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CarGlyph(row.image, Modifier.size(24.dp)) + Spacer(Modifier.width(8.dp)) + Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +/** + * One fact about a place, tighter than a list row: a small label over its value, or just the + * value when the row has no label. A phone number gets a Call button that asks first. + */ +@Composable +private fun DetailRow(row: CarRow, placeName: String) { + val context = LocalContext.current + val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() } + val label = row.title.text().takeIf { texts.isNotEmpty() } + val values = texts.ifEmpty { listOf(row.title.text()) } + val number = values.firstOrNull { it.isPhoneNumber() } + val canCall = number != null && + context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED + Row( + Modifier + .fillMaxWidth() + .let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it } + .padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + row.image?.let { + CarGlyph(it, Modifier.size(24.dp)) + Spacer(Modifier.width(12.dp)) + } + Column(Modifier.weight(1f)) { + label?.let { + Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + values.take(3).forEach { + Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + if (canCall) { + Spacer(Modifier.width(8.dp)) + GsIconBox( + onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) }, + colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary), + shape = RoundedCornerShape(24.dp), + ) { + 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) + } + } + } + ActionRow(row.actions.orEmpty()) + } +} + +/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */ +private fun String.isPhoneNumber(): Boolean { + val t = trim() + if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false + return t.count { it.isDigit() } in 7..15 +} + @Composable internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) { val toggle = row.toggle