mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
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:
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.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<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)
|
||||
}
|
||||
|
||||
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<Offset>, val head: List<Offset>)
|
||||
|
||||
/**
|
||||
* 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<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.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 ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -751,6 +751,10 @@
|
||||
<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_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 -->
|
||||
<string name="template_nothing_playing">Nothing playing yet.</string>
|
||||
|
||||
Reference in new issue
Block a user