mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
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.
This commit is contained in:
1 parent
23f12884da
commit
239be1256b
3 files changed
+154
-1
No files matched your search
@@ -227,6 +227,7 @@ fun CarUi() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
CarToastOverlay(Modifier.align(Alignment.BottomCenter))
|
CarToastOverlay(Modifier.align(Alignment.BottomCenter))
|
||||||
|
CallPromptOverlay()
|
||||||
PopupTimer()
|
PopupTimer()
|
||||||
// The dashboard is already listing them, so it needs no popup.
|
// The dashboard is already listing them, so it needs no popup.
|
||||||
NotificationPopup(
|
NotificationPopup(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
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<Contact?>(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_CONTACTS = 0
|
||||||
private const val TAB_KEYPAD = 1
|
private const val TAB_KEYPAD = 1
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import androidx.compose.foundation.lazy.grid.items
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Warning
|
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.
|
// 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 rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
|
||||||
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
|
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
|
||||||
|
val card = PlaceCard.of(rows)
|
||||||
Column(modifier) {
|
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) {
|
if (autoStart && actions.size == 1) {
|
||||||
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
||||||
} else {
|
} else {
|
||||||
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
|
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<CarRow>, val details: List<CarRow>) {
|
||||||
|
companion object {
|
||||||
|
fun of(rows: List<CarRow>): 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<CarRow>, 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
|
@Composable
|
||||||
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
|
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
|
||||||
val toggle = row.toggle
|
val toggle = row.toggle
|
||||||
|
|||||||
Reference in new issue
Block a user