From c389c1c586a5796cafec7e5ab9f97a25cba19757 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Thu, 5 Nov 2026 16:31:47 -0800 Subject: [PATCH] One navigation card: turn, lanes, a clearer next turn and the trip, with the app's own stop button when it has one --- .../seb3thehacker/gearslip/car/LaneRoad.kt | 145 ++++---- .../gearslip/car/TemplateChrome.kt | 312 +++++++++++------- gearslip/src/main/res/values/strings.xml | 4 + 3 files changed, 270 insertions(+), 191 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/LaneRoad.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/LaneRoad.kt index 68086ac..bb6a106 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/LaneRoad.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/LaneRoad.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.StrokeCap 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.lerp import androidx.compose.ui.platform.LocalContext @@ -112,54 +111,73 @@ internal fun LaneRoad(lanes: List, 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) } - val top = h * 0.1f + val top = h * 0.12f 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 -> - // 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) } + // 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 on = if (lane.recommended()) onFill else line - // Solid colours, faded by mixing rather than transparency, so where a stem and a head - // overlap nothing shows through twice. Faded first, so the one to take draws on top. - directions.sortedBy { it?.isRecommended == true }.forEach { d -> + arrows.forEach { (d, arrow) -> + // Solid colours, faded by mixing rather than transparency, so where a stem runs + // into its head nothing shows through twice. 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, val head: List) + /** - * One direction's arrow inside a lane: a stem up from [bottom] that carries on straight, bends - * 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. + * 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 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 +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 -> -40f - LaneDirection.SHAPE_SLIGHT_RIGHT -> 40f + 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 @@ -169,50 +187,47 @@ private fun DrawScope.laneArrow( else -> 0f } val side = if (degrees < 0) -1f else 1f + val line = mutableListOf(start) + val end: Offset + val dir: Offset when { degrees == 0f -> { - path.to(Offset(0f, top + head)) - tip = Offset(0f, top) + 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 = 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) + 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 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 + 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 } } - 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( - 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, - ) + 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 = (1..10).map { k -> + val t = k / 10f + a * ((1 - t) * (1 - t)) + b * (2 * (1 - t) * t) + c * (t * t) } /** diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt index b72c90a..9185246 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt @@ -38,6 +38,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment 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.draw.clip 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 - * buttons at the right edge. The whole column counts as covered, so the app centres its map in + * 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 * 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. */ @@ -221,16 +223,14 @@ private fun NavigationBars( val column = cardColumnWidth(maxWidth) val cards = @Composable { if (routing != null || estimate != null) { - Column( - Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - // The estimate always gets its full height; the turn card has what's left and - // keeps its top, the part that matters, if it ever runs out of room. - Box(Modifier.weight(1f).fillMaxWidth().clip(RoundedCornerShape(16.dp))) { - routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) } - } - estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) } + Column(Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp)) { + // As tall as what it holds, up to the column's height. + NavigationCard( + routing, + estimate, + showStop = !template.actionStrip.hasStop(), + Modifier.fillMaxWidth().weight(1f, fill = false), + ) } } } @@ -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 - * and onto which road, the lanes to be in, and the turn after it along the bottom. + * The drive in one card, top to bottom: the manoeuvre big enough to read at a glance, the lanes + * 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 -private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) { - if (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 +private fun NavigationCard(info: RoutingInfo?, estimate: TravelEstimate?, showStop: Boolean, modifier: Modifier) { + val step = info?.currentStep?.takeUnless { info.isLoading } ChromeSurface(modifier) { - // On a short screen the hint goes first, then the next turn, then the lanes; never the turn. FitColumn { - Column( - Modifier.layoutId(0).fillMaxWidth().background(scheme.primaryContainer).padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - androidx.compose.runtime.CompositionLocalProvider( - androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - CarGlyph(step.maneuver?.icon, Modifier.size(64.dp)) - Spacer(Modifier.width(14.dp)) - val distance = info.currentDistance.display() - if (distance.isNotEmpty()) { - Text( - distance, - style = MaterialTheme.typography.displaySmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), - maxLines = 1, - ) - } - } - val cue = step.cue.text() - if (cue.isNotEmpty()) { - Text(cue, style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - val road = step.road.text() - // "Turn left onto Queensberry Drive" already says the road; saying it twice is noise. - if (road.isNotEmpty() && !cue.contains(road, ignoreCase = true)) { - Text( - road, - style = ChromeType.body, - color = scheme.onPrimaryContainer.copy(alpha = 0.8f), - 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) } + 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( + modifier.background(scheme.primaryContainer).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + CarGlyph(step.maneuver?.icon, Modifier.size(64.dp)) + Spacer(Modifier.width(14.dp)) + val distance = info.currentDistance.display() + if (distance.isNotEmpty()) { + Text( + distance, + 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. - step.lanes.orEmpty().takeIf { it.isNotEmpty() }?.let { - LaneBand(it, Modifier.layoutId(1).fillMaxWidth()) - LaneHintLine(it, Modifier.layoutId(3).fillMaxWidth()) + val cue = step.cue.text() + if (cue.isNotEmpty()) { + Text(cue, style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis) } - info.nextStep?.let { next -> - Row( - Modifier.layoutId(2).fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(stringResource(R.string.nav_then), style = ChromeType.label, color = scheme.onSurfaceVariant) - Spacer(Modifier.width(10.dp)) - CarGlyph(next.maneuver?.icon, Modifier.size(28.dp)) - val nextText = next.cue.text().ifEmpty { next.road.text() } - if (nextText.isNotEmpty()) { - Spacer(Modifier.width(10.dp)) - Text(nextText, style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } + val road = step.road.text() + // "Turn left onto Queensberry Drive" already says the road; saying it twice is noise. + if (road.isNotEmpty() && !cue.contains(road, ignoreCase = true)) { + Text( + road, + style = ChromeType.body, + color = scheme.onPrimaryContainer.copy(alpha = 0.8f), + 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 - * distance, with Gearslip's own stop button beside them. The close button is Gearslip's, not the - * app's: an actionStrip's actions carry no flag saying which one (if any) means "stop - * navigating" - the first button turned out to be a mute toggle, not an exit - so this ends - * navigation the way going Home already does rather than guess at the app's own controls. + * The trip along the bottom of the card: time left, coloured as the app colours it (by traffic, + * in Vela), then the arrival and the distance, each with what it is under it. Gearslip's stop + * button sits at the end unless the app shows its own ([hasStop]). It ends navigation the way + * going Home does: the connection stays up and the app decides its own next screen. */ @Composable -private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Modifier) { +private fun TripRow(estimate: TravelEstimate, showStop: Boolean, modifier: Modifier) { val connection = LocalCarAppConnection.current + val scheme = MaterialTheme.colorScheme val time = remainingTime(estimate.remainingTimeSeconds) val distance = estimate.remainingDistance.display() val arrival = estimate.arrivalTimeAtDestination.clockTime() val trip = estimate.tripText.text() if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return - - ChromeSurface(modifier) { - val dark = MaterialTheme.colorScheme.surface.luminanceIsDark() - Row( - Modifier.padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - if (time.isNotEmpty()) { - Text( - time, - 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) - } + val dark = scheme.surface.luminanceIsDark() + Column(modifier.padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 12.dp)) { + Row(Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) { + // The time leads, large and coloured; arrival and distance are smaller, each in its + // own column behind a thin rule, with what it is under it. + TripFigure(time, null, estimate.remainingTimeColor.color(dark, scheme.onSurface), ChromeType.headline, Modifier.weight(1.1f)) + TripRule() + TripFigure(arrival, stringResource(R.string.nav_arrival), scheme.onSurface, ChromeType.body, Modifier.weight(1f)) + TripRule() + TripFigure(distance, stringResource(R.string.nav_to_go), scheme.onSurface, ChromeType.body, Modifier.weight(1f)) + if (showStop) { + GsIconButton( + Icons.Filled.Close, + stringResource(R.string.nav_stop), + { connection.stopNavigating() }, + colors = GsColors(scheme.surfaceContainerHighest, scheme.onSurface), + size = 52.dp, + iconSize = 26.dp, + shape = CircleShape, + ) } - // 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( - Icons.Filled.Close, - stringResource(R.string.nav_stop), - { connection.stopNavigating() }, - colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface), - size = 52.dp, - iconSize = 26.dp, - 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 ---------------------------------------------------------------------- /** diff --git a/gearslip/src/main/res/values/strings.xml b/gearslip/src/main/res/values/strings.xml index 6048505..a4a9192 100644 --- a/gearslip/src/main/res/values/strings.xml +++ b/gearslip/src/main/res/values/strings.xml @@ -751,6 +751,10 @@ Use lane %1$d from the right Any lane Arrive %1$s + + arrival + + to go Nothing playing yet.