One navigation card: turn, lanes, a clearer next turn and the trip, with the app's own stop button when it has one

This commit is contained in:
Seb3thehacker committed 2026-11-05 16:44:22 -08:00
1 parent 5d1d2337f0
commit c389c1c586
3 files changed
+246 -167

No files matched your search

@@ -17,7 +17,6 @@ import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -112,98 +111,44 @@ internal fun LaneRoad(lanes: List<Lane>, modifier: Modifier, fill: Color, onFill
drawLine(lerp(ground, line, 0.7f), Offset(x(i.toFloat(), 0f), 0f), Offset(x(i.toFloat(), h), h), edge, pathEffect = dashes) drawLine(lerp(ground, line, 0.7f), Offset(x(i.toFloat(), 0f), 0f), Offset(x(i.toFloat(), h), h), edge, pathEffect = dashes)
} }
val top = h * 0.1f val top = h * 0.12f
val bottom = h * 0.9f val bottom = h * 0.9f
val half = minOf(bottomWidth / n * 0.34f, h * 0.4f) // Half the room across a lane the arrows may use, as at the bottom: well inside the lines
// even where the lane narrows at the top.
val room = bottomWidth / n * 0.31f
lanes.forEachIndexed { i, lane -> lanes.forEachIndexed { i, lane ->
// A point [u] across from the lane's middle, measured as at the bottom, at height [y].
val onRoad = { u: Float, y: Float -> Offset(x(i + 0.5f, y) + u * width(y) / bottomWidth, y) }
val stroke = half * 0.24f * width((top + bottom) / 2) / bottomWidth
val directions = lane.directions.orEmpty().ifEmpty { listOf(null) } val directions = lane.directions.orEmpty().ifEmpty { listOf(null) }
// Faded first, so the one to take draws on top where they share a stem.
val arrows = directions.sortedBy { it?.isRecommended == true }
.map { it to laneArrow(it?.shape ?: LaneDirection.SHAPE_STRAIGHT, top, bottom) }
// The lane's arrows as one shape: centred in the lane, and shrunk toward the stem's
// foot when they'd reach past [room].
val xs = arrows.flatMap { (_, a) -> a.line.map { it.x } + a.head.map { it.x } }
val shift = -(xs.min() + xs.max()) / 2
val scale = minOf(1f, room / ((xs.max() - xs.min()) / 2))
val lift = (bottom - top) * (1 - scale) / 2
// Flat to the road: centred, scaled, then narrowed with the lane toward the top.
val onRoad = { p: Offset ->
val y = bottom - (bottom - p.y) * scale - lift
Offset(x(i + 0.5f, y) + (p.x + shift) * scale * width(y) / bottomWidth, y)
}
val stroke = (bottom - top) * 0.085f * scale * width((top + bottom) / 2) / bottomWidth
val behind = if (lane.recommended()) fill else ground val behind = if (lane.recommended()) fill else ground
val on = if (lane.recommended()) onFill else line val on = if (lane.recommended()) onFill else line
// Solid colours, faded by mixing rather than transparency, so where a stem and a head arrows.forEach { (d, arrow) ->
// overlap nothing shows through twice. Faded first, so the one to take draws on top. // Solid colours, faded by mixing rather than transparency, so where a stem runs
directions.sortedBy { it?.isRecommended == true }.forEach { d -> // into its head nothing shows through twice.
val color = if (d?.isRecommended == true) on else lerp(behind, on, 0.38f) val color = if (d?.isRecommended == true) on else lerp(behind, on, 0.38f)
laneArrow(d?.shape ?: LaneDirection.SHAPE_STRAIGHT, onRoad, top, bottom, half, stroke, color) val points = arrow.line.map(onRoad)
} drawPath(
} Path().apply {
} moveTo(points[0].x, points[0].y)
} points.drop(1).forEach { lineTo(it.x, it.y) }
},
/** color,
* One direction's arrow inside a lane: a stem up from [bottom] that carries on straight, bends style = Stroke(stroke, cap = StrokeCap.Round, join = StrokeJoin.Round),
* off to a side, or loops back for a U-turn, with a head at the end. Laid out flat, [half] being )
* half the room across the lane, then put on the road by [onRoad]. A lane's arrows share a stem. val corners = arrow.head.map(onRoad)
*/
private fun DrawScope.laneArrow(
shape: Int,
onRoad: (Float, Float) -> Offset,
top: Float,
bottom: Float,
half: Float,
stroke: Float,
color: Color,
) {
val head = half * 0.24f * 1.9f
val path = Path()
fun Path.to(p: Offset) = onRoad(p.x, p.y).let { lineTo(it.x, it.y) }
fun Path.curve(c: Offset, p: Offset) {
val a = onRoad(c.x, c.y)
val b = onRoad(p.x, p.y)
quadraticTo(a.x, a.y, b.x, b.y)
}
onRoad(0f, bottom).let { path.moveTo(it.x, it.y) }
val tip: Offset
val dir: Offset
val degrees = when (shape) {
LaneDirection.SHAPE_SLIGHT_LEFT -> -40f
LaneDirection.SHAPE_SLIGHT_RIGHT -> 40f
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
LaneDirection.SHAPE_SHARP_LEFT -> -135f
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
LaneDirection.SHAPE_U_TURN_LEFT -> -180f
LaneDirection.SHAPE_U_TURN_RIGHT -> 180f
else -> 0f
}
val side = if (degrees < 0) -1f else 1f
when {
degrees == 0f -> {
path.to(Offset(0f, top + head))
tip = Offset(0f, top)
dir = Offset(0f, -1f)
}
abs(degrees) == 180f -> {
// Up, over the top and back down beside the stem.
val r = half * 0.45f
val back = side * 2 * r
path.to(Offset(0f, top + r))
path.curve(Offset(0f, top), Offset(side * r, top))
path.curve(Offset(back, top), Offset(back, top + r))
val end = top + (bottom - top) * 0.55f
path.to(Offset(back, end))
tip = Offset(back, end + head)
dir = Offset(0f, 1f)
}
else -> {
val rad = degrees / 180f * PI.toFloat()
val d = Offset(sin(rad), -cos(rad))
val fork = top + (bottom - top) * 0.5f
val length = minOf((bottom - top) * 0.42f, (half - head) / abs(d.x))
val bend = Offset(0f, fork - length * 0.45f)
val end = bend + d * length
path.to(Offset(0f, fork))
path.curve(bend, end)
tip = end + d * head
dir = d
}
}
drawPath(path, color, style = Stroke(stroke, cap = StrokeCap.Round, join = StrokeJoin.Round))
val across = Offset(-dir.y, dir.x) * (head * 0.85f)
val base = tip - dir * (head * 1.6f)
val corners = listOf(tip, base + across, base - across).map { onRoad(it.x, it.y) }
drawPath( drawPath(
Path().apply { Path().apply {
moveTo(corners[0].x, corners[0].y) moveTo(corners[0].x, corners[0].y)
@@ -213,6 +158,76 @@ private fun DrawScope.laneArrow(
}, },
color, color,
) )
}
}
}
}
/** An arrow laid out flat: the stem as points, and the head's tip and two back corners. */
private class LaneArrow(val line: List<Offset>, val head: List<Offset>)
/**
* One direction's arrow, flat, its stem rising from x 0 at [bottom]: on straight to [top], bending
* off to a side from a little above the middle, or looping back for a U-turn. A lane's arrows
* share the stem, so a straight-and-right lane reads as one fork.
*/
private fun laneArrow(shape: Int, top: Float, bottom: Float): LaneArrow {
val tall = bottom - top
val head = tall * 0.17f
val start = Offset(0f, bottom)
val degrees = when (shape) {
LaneDirection.SHAPE_SLIGHT_LEFT -> -45f
LaneDirection.SHAPE_SLIGHT_RIGHT -> 45f
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
LaneDirection.SHAPE_SHARP_LEFT -> -135f
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
LaneDirection.SHAPE_U_TURN_LEFT -> -180f
LaneDirection.SHAPE_U_TURN_RIGHT -> 180f
else -> 0f
}
val side = if (degrees < 0) -1f else 1f
val line = mutableListOf(start)
val end: Offset
val dir: Offset
when {
degrees == 0f -> {
end = Offset(0f, top + head)
line += end
dir = Offset(0f, -1f)
}
abs(degrees) == 180f -> {
// Up, over the top and back down beside the stem.
val r = tall * 0.2f
line += Offset(0f, top + r)
line += curve(Offset(0f, top + r), Offset(0f, top), Offset(side * r, top))
line += curve(Offset(side * r, top), Offset(side * 2 * r, top), Offset(side * 2 * r, top + r))
end = Offset(side * 2 * r, top + tall * 0.5f)
line += end
dir = Offset(0f, 1f)
}
else -> {
val rad = degrees / 180f * PI.toFloat()
val d = Offset(sin(rad), -cos(rad))
val length = tall * 0.32f
val fork = Offset(0f, top + tall * 0.6f)
val bend = Offset(0f, fork.y - length * 0.45f)
end = bend + d * length
line += fork
line += curve(fork, bend, end)
dir = d
}
}
val tip = end + dir * head
val base = tip - dir * (head * 1.5f)
val across = Offset(-dir.y, dir.x) * (head * 0.9f)
return LaneArrow(line, listOf(tip, base + across, base - across))
}
/** Points along a quadratic curve from [a] to [c] bending toward [b], [a] itself left out. */
private fun curve(a: Offset, b: Offset, c: Offset): List<Offset> = (1..10).map { k ->
val t = k / 10f
a * ((1 - t) * (1 - t)) + b * (2 * (1 - t) * t) + c * (t * t)
} }
/** /**
@@ -38,6 +38,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.runtime.setValue
import androidx.compose.ui.layout.layoutId import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -200,8 +203,7 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
} }
/** /**
* Cards down the left, map on the right: the turn on top, the trip along the bottom, the map * The drive's card down the left, map on the right, the map buttons at the right edge. The whole column counts as covered, so the app centres its map in
* buttons at the right edge. The whole column counts as covered, so the app centres its map in
* what is left over and the car sits right of the cards rather than under them. Pan mode drops * what is left over and the car sits right of the cards rather than under them. Pan mode drops
* the cards and gives the map the whole width. * the cards and gives the map the whole width.
*/ */
@@ -221,16 +223,14 @@ private fun NavigationBars(
val column = cardColumnWidth(maxWidth) val column = cardColumnWidth(maxWidth)
val cards = @Composable { val cards = @Composable {
if (routing != null || estimate != null) { if (routing != null || estimate != null) {
Column( Column(Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp)) {
Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp), // As tall as what it holds, up to the column's height.
verticalArrangement = Arrangement.spacedBy(12.dp), NavigationCard(
) { routing,
// The estimate always gets its full height; the turn card has what's left and estimate,
// keeps its top, the part that matters, if it ever runs out of room. showStop = !template.actionStrip.hasStop(),
Box(Modifier.weight(1f).fillMaxWidth().clip(RoundedCornerShape(16.dp))) { Modifier.fillMaxWidth().weight(1f, fill = false),
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) } )
}
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
} }
} }
} }
@@ -252,25 +252,42 @@ private fun NavigationBars(
} }
/** /**
* The manoeuvre, big enough to read at a glance: the arrow and the distance to it, what to do * The drive in one card, top to bottom: the manoeuvre big enough to read at a glance, the lanes
* and onto which road, the lanes to be in, and the turn after it along the bottom. * to be in, the turn after it, and the trip. On a short screen parts drop out whole, the lanes'
* words first, then the next turn, then the lanes; the manoeuvre and the trip always stay.
*/ */
@Composable @Composable
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) { private fun NavigationCard(info: RoutingInfo?, estimate: TravelEstimate?, showStop: Boolean, modifier: Modifier) {
if (info.isLoading) { val step = info?.currentStep?.takeUnless { info.isLoading }
ChromeSurface(modifier) { ChromeSurface(modifier) {
Text(stringResource(R.string.nav_finding_route), Modifier.padding(horizontal = 18.dp, vertical = 16.dp), style = ChromeType.title)
}
return
}
val step = info.currentStep ?: return
val scheme = MaterialTheme.colorScheme
ChromeSurface(modifier) {
// On a short screen the hint goes first, then the next turn, then the lanes; never the turn.
FitColumn { FitColumn {
if (info?.isLoading == true) {
Text(
stringResource(R.string.nav_finding_route),
Modifier.layoutId(0).padding(horizontal = 18.dp, vertical = 16.dp),
style = ChromeType.title,
)
}
if (info != null && step != null) {
ManeuverHeader(info, step, Modifier.layoutId(0).fillMaxWidth())
// Drawn from the lane data whenever there is some, even beside the app's own picture.
step.lanes.orEmpty().takeIf { it.isNotEmpty() }?.let {
LaneBand(it, Modifier.layoutId(2).fillMaxWidth())
LaneHintLine(it, Modifier.layoutId(4).fillMaxWidth())
}
info.nextStep?.let { NextTurn(it, Modifier.layoutId(3).fillMaxWidth()) }
}
estimate?.let { TripRow(it, showStop, Modifier.layoutId(1).fillMaxWidth()) }
}
}
}
/** The manoeuvre on the theme's colour: the arrow and the distance to it, what to do, onto which road. */
@Composable
private fun ManeuverHeader(info: RoutingInfo, step: Step, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
Column( Column(
Modifier.layoutId(0).fillMaxWidth().background(scheme.primaryContainer).padding(16.dp), modifier.background(scheme.primaryContainer).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
androidx.compose.runtime.CompositionLocalProvider( androidx.compose.runtime.CompositionLocalProvider(
@@ -283,7 +300,7 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
if (distance.isNotEmpty()) { if (distance.isNotEmpty()) {
Text( Text(
distance, distance,
style = MaterialTheme.typography.displaySmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),
maxLines = 1, maxLines = 1,
) )
} }
@@ -311,25 +328,36 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
info.junctionImage?.let { JunctionImage(it) } info.junctionImage?.let { JunctionImage(it) }
} }
} }
// Drawn from the lane data whenever there is some, even beside the app's own picture. }
step.lanes.orEmpty().takeIf { it.isNotEmpty() }?.let {
LaneBand(it, Modifier.layoutId(1).fillMaxWidth()) /**
LaneHintLine(it, Modifier.layoutId(3).fillMaxWidth()) * The turn after this one, as large as the instruction above it and wrapping rather than cut
} * short: leaving a motorway, the next move comes quickly and has to read in a glance.
info.nextStep?.let { next -> */
@Composable
private fun NextTurn(next: Step, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
// A shade apart from the trip below, so the two never read as one block of text.
Row( Row(
Modifier.layoutId(2).fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), modifier.background(scheme.surfaceContainerHighest).padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text(stringResource(R.string.nav_then), style = ChromeType.label, color = scheme.onSurfaceVariant) CarGlyph(next.maneuver?.icon, Modifier.size(40.dp))
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(14.dp))
CarGlyph(next.maneuver?.icon, Modifier.size(28.dp)) Column {
val nextText = next.cue.text().ifEmpty { next.road.text() } Text(
if (nextText.isNotEmpty()) { stringResource(R.string.nav_then).uppercase(java.util.Locale.getDefault()),
Spacer(Modifier.width(10.dp)) style = ChromeType.label.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp),
Text(nextText, style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis) color = scheme.primary,
} )
} val text = next.cue.text().ifEmpty { next.road.text() }
if (text.isNotEmpty()) {
Text(
text,
style = ChromeType.body.copy(fontWeight = FontWeight.SemiBold),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
} }
} }
} }
@@ -381,63 +409,95 @@ private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
} }
/** /**
* The trip at a glance, stacked: time left on top, then the clock time it ends at and the * The trip along the bottom of the card: time left, coloured as the app colours it (by traffic,
* distance, with Gearslip's own stop button beside them. The close button is Gearslip's, not the * in Vela), then the arrival and the distance, each with what it is under it. Gearslip's stop
* app's: an actionStrip's actions carry no flag saying which one (if any) means "stop * button sits at the end unless the app shows its own ([hasStop]). It ends navigation the way
* navigating" - the first button turned out to be a mute toggle, not an exit - so this ends * going Home does: the connection stays up and the app decides its own next screen.
* navigation the way going Home already does rather than guess at the app's own controls.
*/ */
@Composable @Composable
private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Modifier) { private fun TripRow(estimate: TravelEstimate, showStop: Boolean, modifier: Modifier) {
val connection = LocalCarAppConnection.current val connection = LocalCarAppConnection.current
val scheme = MaterialTheme.colorScheme
val time = remainingTime(estimate.remainingTimeSeconds) val time = remainingTime(estimate.remainingTimeSeconds)
val distance = estimate.remainingDistance.display() val distance = estimate.remainingDistance.display()
val arrival = estimate.arrivalTimeAtDestination.clockTime() val arrival = estimate.arrivalTimeAtDestination.clockTime()
val trip = estimate.tripText.text() val trip = estimate.tripText.text()
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
val dark = scheme.surface.luminanceIsDark()
ChromeSurface(modifier) { Column(modifier.padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 12.dp)) {
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark() Row(Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
Row( // The time leads, large and coloured; arrival and distance are smaller, each in its
Modifier.padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 12.dp), // own column behind a thin rule, with what it is under it.
verticalAlignment = Alignment.CenterVertically, TripFigure(time, null, estimate.remainingTimeColor.color(dark, scheme.onSurface), ChromeType.headline, Modifier.weight(1.1f))
) { TripRule()
Column(Modifier.weight(1f)) { TripFigure(arrival, stringResource(R.string.nav_arrival), scheme.onSurface, ChromeType.body, Modifier.weight(1f))
if (time.isNotEmpty()) { TripRule()
Text( TripFigure(distance, stringResource(R.string.nav_to_go), scheme.onSurface, ChromeType.body, Modifier.weight(1f))
time, if (showStop) {
style = ChromeType.headline,
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
maxLines = 1,
)
}
val detail = listOfNotNull(
arrival.takeIf { it.isNotEmpty() }?.let { stringResource(R.string.nav_arrive, it) },
distance.takeIf { it.isNotEmpty() },
).joinToString(" • ")
if (detail.isNotEmpty()) {
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (trip.isNotEmpty()) {
Text(trip, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
// Not a disconnect: the connection stays up and the app decides its own next screen.
// Ending navigation is its own call, separate from a back press - a back press only
// pops the on-screen card, leaving the app still believing it is navigating.
GsIconButton( GsIconButton(
Icons.Filled.Close, Icons.Filled.Close,
stringResource(R.string.nav_stop), stringResource(R.string.nav_stop),
{ connection.stopNavigating() }, { connection.stopNavigating() },
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface), colors = GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
size = 52.dp, size = 52.dp,
iconSize = 26.dp, iconSize = 26.dp,
shape = CircleShape, shape = CircleShape,
) )
} }
} }
if (trip.isNotEmpty()) {
Text(
trip,
Modifier.padding(top = 6.dp),
style = ChromeType.body,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
} }
@Composable
private fun TripRule() {
Box(
Modifier.padding(horizontal = 12.dp, vertical = 4.dp).width(1.dp).fillMaxHeight()
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
/** One of the trip's figures with its label under it, shrinking to stay on one line in a narrow column. */
@Composable
private fun TripFigure(value: String, label: String?, color: Color, style: androidx.compose.ui.text.TextStyle, modifier: Modifier) {
Column(modifier) {
if (value.isEmpty()) return@Column
var size by androidx.compose.runtime.remember(value, style) { androidx.compose.runtime.mutableStateOf(style.fontSize) }
Text(
value,
style = style.copy(fontSize = size, fontWeight = FontWeight.Bold),
color = color,
maxLines = 1,
softWrap = false,
onTextLayout = { if (it.hasVisualOverflow && size.value > 12f) size *= 0.9f },
)
if (label != null) {
Text(label, style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
/**
* Whether the app's own buttons include one that ends the drive: a red primary button, as
* Vela's is, or one named for it. Actions carry no flag that says so, so this is the best sign.
*/
private fun androidx.car.app.model.ActionStrip?.hasStop(): Boolean = this?.actions.orEmpty().any { action ->
val red = action.flags and androidx.car.app.model.Action.FLAG_PRIMARY != 0 &&
action.backgroundColor?.type == androidx.car.app.model.CarColor.TYPE_RED
red || action.title?.toString()?.let { STOP_WORDS.containsMatchIn(it) } == true
}
private val STOP_WORDS = Regex("\\b(stop|end|exit|quit|cancel)\\b", RegexOption.IGNORE_CASE)
// --- map with a side pane ---------------------------------------------------------------------- // --- map with a side pane ----------------------------------------------------------------------
/** /**
+4
View File
@@ -751,6 +751,10 @@
<string name="nav_lane_from_right">Use lane %1$d from the right</string> <string name="nav_lane_from_right">Use lane %1$d from the right</string>
<string name="nav_lane_any">Any lane</string> <string name="nav_lane_any">Any lane</string>
<string name="nav_arrive">Arrive %1$s</string> <string name="nav_arrive">Arrive %1$s</string>
<!-- Under the arrival time on the trip line: "4:30 p.m. / arrival" -->
<string name="nav_arrival">arrival</string>
<!-- Under the distance left on the trip line: "2.7 km / to go" -->
<string name="nav_to_go">to go</string>
<!-- Car app screens --> <!-- Car app screens -->
<string name="template_nothing_playing">Nothing playing yet.</string> <string name="template_nothing_playing">Nothing playing yet.</string>