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
+270 -191

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,54 +111,73 @@ 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,
style = Stroke(stroke, cap = StrokeCap.Round, join = StrokeJoin.Round),
)
val corners = arrow.head.map(onRoad)
drawPath(
Path().apply {
moveTo(corners[0].x, corners[0].y)
lineTo(corners[1].x, corners[1].y)
lineTo(corners[2].x, corners[2].y)
close()
},
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 inside a lane: a stem up from [bottom] that carries on straight, bends * One direction's arrow, flat, its stem rising from x 0 at [bottom]: on straight to [top], bending
* off to a side, or loops back for a U-turn, with a head at the end. Laid out flat, [half] being * off to a side from a little above the middle, or looping back for a U-turn. A lane's arrows
* half the room across the lane, then put on the road by [onRoad]. A lane's arrows share a stem. * share the stem, so a straight-and-right lane reads as one fork.
*/ */
private fun DrawScope.laneArrow( private fun laneArrow(shape: Int, top: Float, bottom: Float): LaneArrow {
shape: Int, val tall = bottom - top
onRoad: (Float, Float) -> Offset, val head = tall * 0.17f
top: Float, val start = Offset(0f, bottom)
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) { val degrees = when (shape) {
LaneDirection.SHAPE_SLIGHT_LEFT -> -40f LaneDirection.SHAPE_SLIGHT_LEFT -> -45f
LaneDirection.SHAPE_SLIGHT_RIGHT -> 40f LaneDirection.SHAPE_SLIGHT_RIGHT -> 45f
LaneDirection.SHAPE_NORMAL_LEFT -> -90f LaneDirection.SHAPE_NORMAL_LEFT -> -90f
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
LaneDirection.SHAPE_SHARP_LEFT -> -135f LaneDirection.SHAPE_SHARP_LEFT -> -135f
@@ -169,50 +187,47 @@ private fun DrawScope.laneArrow(
else -> 0f else -> 0f
} }
val side = if (degrees < 0) -1f else 1f val side = if (degrees < 0) -1f else 1f
val line = mutableListOf(start)
val end: Offset
val dir: Offset
when { when {
degrees == 0f -> { degrees == 0f -> {
path.to(Offset(0f, top + head)) end = Offset(0f, top + head)
tip = Offset(0f, top) line += end
dir = Offset(0f, -1f) dir = Offset(0f, -1f)
} }
abs(degrees) == 180f -> { abs(degrees) == 180f -> {
// Up, over the top and back down beside the stem. // Up, over the top and back down beside the stem.
val r = half * 0.45f val r = tall * 0.2f
val back = side * 2 * r line += Offset(0f, top + r)
path.to(Offset(0f, top + r)) line += curve(Offset(0f, top + r), Offset(0f, top), Offset(side * r, top))
path.curve(Offset(0f, top), Offset(side * r, top)) line += curve(Offset(side * r, top), Offset(side * 2 * r, top), Offset(side * 2 * r, top + r))
path.curve(Offset(back, top), Offset(back, top + r)) end = Offset(side * 2 * r, top + tall * 0.5f)
val end = top + (bottom - top) * 0.55f line += end
path.to(Offset(back, end))
tip = Offset(back, end + head)
dir = Offset(0f, 1f) dir = Offset(0f, 1f)
} }
else -> { else -> {
val rad = degrees / 180f * PI.toFloat() val rad = degrees / 180f * PI.toFloat()
val d = Offset(sin(rad), -cos(rad)) val d = Offset(sin(rad), -cos(rad))
val fork = top + (bottom - top) * 0.5f val length = tall * 0.32f
val length = minOf((bottom - top) * 0.42f, (half - head) / abs(d.x)) val fork = Offset(0f, top + tall * 0.6f)
val bend = Offset(0f, fork - length * 0.45f) val bend = Offset(0f, fork.y - length * 0.45f)
val end = bend + d * length end = bend + d * length
path.to(Offset(0f, fork)) line += fork
path.curve(bend, end) line += curve(fork, bend, end)
tip = end + d * head
dir = d dir = d
} }
} }
drawPath(path, color, style = Stroke(stroke, cap = StrokeCap.Round, join = StrokeJoin.Round)) val tip = end + dir * head
val across = Offset(-dir.y, dir.x) * (head * 0.85f) val base = tip - dir * (head * 1.5f)
val base = tip - dir * (head * 1.6f) val across = Offset(-dir.y, dir.x) * (head * 0.9f)
val corners = listOf(tip, base + across, base - across).map { onRoad(it.x, it.y) } return LaneArrow(line, listOf(tip, base + across, base - across))
drawPath( }
Path().apply {
moveTo(corners[0].x, corners[0].y) /** Points along a quadratic curve from [a] to [c] bending toward [b], [a] itself left out. */
lineTo(corners[1].x, corners[1].y) private fun curve(a: Offset, b: Offset, c: Offset): List<Offset> = (1..10).map { k ->
lineTo(corners[2].x, corners[2].y) val t = k / 10f
close() a * ((1 - t) * (1 - t)) + b * (2 * (1 - t) * t) + c * (t * t)
},
color,
)
} }
/** /**
@@ -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,84 +252,112 @@ 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) {
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) { ChromeSurface(modifier) {
// On a short screen the hint goes first, then the next turn, then the lanes; never the turn.
FitColumn { FitColumn {
Column( if (info?.isLoading == true) {
Modifier.layoutId(0).fillMaxWidth().background(scheme.primaryContainer).padding(16.dp), Text(
verticalArrangement = Arrangement.spacedBy(8.dp), stringResource(R.string.nav_finding_route),
) { Modifier.layoutId(0).padding(horizontal = 18.dp, vertical = 16.dp),
androidx.compose.runtime.CompositionLocalProvider( style = ChromeType.title,
androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer, )
) { }
Row(verticalAlignment = Alignment.CenterVertically) { if (info != null && step != null) {
CarGlyph(step.maneuver?.icon, Modifier.size(64.dp)) ManeuverHeader(info, step, Modifier.layoutId(0).fillMaxWidth())
Spacer(Modifier.width(14.dp)) // Drawn from the lane data whenever there is some, even beside the app's own picture.
val distance = info.currentDistance.display() step.lanes.orEmpty().takeIf { it.isNotEmpty() }?.let {
if (distance.isNotEmpty()) { LaneBand(it, Modifier.layoutId(2).fillMaxWidth())
Text( LaneHintLine(it, Modifier.layoutId(4).fillMaxWidth())
distance, }
style = MaterialTheme.typography.displaySmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), info.nextStep?.let { NextTurn(it, Modifier.layoutId(3).fillMaxWidth()) }
maxLines = 1, }
) estimate?.let { TripRow(it, showStop, Modifier.layoutId(1).fillMaxWidth()) }
} }
} }
val cue = step.cue.text() }
if (cue.isNotEmpty()) {
Text(cue, style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis) /** The manoeuvre on the theme's colour: the arrow and the distance to it, what to do, onto which road. */
} @Composable
val road = step.road.text() private fun ManeuverHeader(info: RoutingInfo, step: Step, modifier: Modifier) {
// "Turn left onto Queensberry Drive" already says the road; saying it twice is noise. val scheme = MaterialTheme.colorScheme
if (road.isNotEmpty() && !cue.contains(road, ignoreCase = true)) { Column(
Text( modifier.background(scheme.primaryContainer).padding(16.dp),
road, verticalArrangement = Arrangement.spacedBy(8.dp),
style = ChromeType.body, ) {
color = scheme.onPrimaryContainer.copy(alpha = 0.8f), androidx.compose.runtime.CompositionLocalProvider(
maxLines = 1, androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer,
overflow = TextOverflow.Ellipsis, ) {
) Row(verticalAlignment = Alignment.CenterVertically) {
} CarGlyph(step.maneuver?.icon, Modifier.size(64.dp))
// An app that sends only its own picture of the lanes gets it shown as it is. Spacer(Modifier.width(14.dp))
if (step.lanes.orEmpty().isEmpty()) { val distance = info.currentDistance.display()
step.lanesImage?.let { JunctionImage(it, Modifier.heightIn(max = 56.dp)) } if (distance.isNotEmpty()) {
} Text(
// Rare, and only where an arrow can't explain the junction: worth the card growing for. distance,
info.junctionImage?.let { JunctionImage(it) } style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),
maxLines = 1,
)
} }
} }
// Drawn from the lane data whenever there is some, even beside the app's own picture. val cue = step.cue.text()
step.lanes.orEmpty().takeIf { it.isNotEmpty() }?.let { if (cue.isNotEmpty()) {
LaneBand(it, Modifier.layoutId(1).fillMaxWidth()) Text(cue, style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
LaneHintLine(it, Modifier.layoutId(3).fillMaxWidth())
} }
info.nextStep?.let { next -> val road = step.road.text()
Row( // "Turn left onto Queensberry Drive" already says the road; saying it twice is noise.
Modifier.layoutId(2).fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), if (road.isNotEmpty() && !cue.contains(road, ignoreCase = true)) {
verticalAlignment = Alignment.CenterVertically, Text(
) { road,
Text(stringResource(R.string.nav_then), style = ChromeType.label, color = scheme.onSurfaceVariant) style = ChromeType.body,
Spacer(Modifier.width(10.dp)) color = scheme.onPrimaryContainer.copy(alpha = 0.8f),
CarGlyph(next.maneuver?.icon, Modifier.size(28.dp)) maxLines = 1,
val nextText = next.cue.text().ifEmpty { next.road.text() } overflow = TextOverflow.Ellipsis,
if (nextText.isNotEmpty()) { )
Spacer(Modifier.width(10.dp)) }
Text(nextText, style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis) // An app that sends only its own picture of the lanes gets it shown as it is.
} if (step.lanes.orEmpty().isEmpty()) {
} step.lanesImage?.let { JunctionImage(it, Modifier.heightIn(max = 56.dp)) }
}
// Rare, and only where an arrow can't explain the junction: worth the card growing for.
info.junctionImage?.let { JunctionImage(it) }
}
}
}
/**
* 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.
*/
@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(
modifier.background(scheme.surfaceContainerHighest).padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CarGlyph(next.maneuver?.icon, Modifier.size(40.dp))
Spacer(Modifier.width(14.dp))
Column {
Text(
stringResource(R.string.nav_then).uppercase(java.util.Locale.getDefault()),
style = ChromeType.label.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp),
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, GsIconButton(
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface), Icons.Filled.Close,
maxLines = 1, stringResource(R.string.nav_stop),
) { connection.stopNavigating() },
} colors = GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
val detail = listOfNotNull( size = 52.dp,
arrival.takeIf { it.isNotEmpty() }?.let { stringResource(R.string.nav_arrive, it) }, iconSize = 26.dp,
distance.takeIf { it.isNotEmpty() }, shape = CircleShape,
).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 if (trip.isNotEmpty()) {
// pops the on-screen card, leaving the app still believing it is navigating. Text(
GsIconButton( trip,
Icons.Filled.Close, Modifier.padding(top = 6.dp),
stringResource(R.string.nav_stop), style = ChromeType.body,
{ connection.stopNavigating() }, color = scheme.onSurfaceVariant,
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface), maxLines = 1,
size = 52.dp, overflow = TextOverflow.Ellipsis,
iconSize = 26.dp,
shape = CircleShape,
) )
} }
} }
} }
@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>