mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Lane guidance draws the road ahead, with the lanes to use filled in and said in words
This commit is contained in:
1 parent
163e206715
commit
5d1d2337f0
4 files changed
+333
-73
No files matched your search
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -103,7 +104,7 @@ internal fun ClusterScreen(marginX: Int, marginY: Int, densityDpi: Int, clearAre
|
|||||||
if (t != null) {
|
if (t != null) {
|
||||||
BoxWithConstraints(Modifier.weight(0.42f).fillMaxHeight(), contentAlignment = Alignment.CenterStart) {
|
BoxWithConstraints(Modifier.weight(0.42f).fillMaxHeight(), contentAlignment = Alignment.CenterStart) {
|
||||||
// Narrower than alone: the arrow and distance alone are about 12 units wide.
|
// Narrower than alone: the arrow and distance alone are about 12 units wide.
|
||||||
TurnCard(t, minOf(maxHeight / 8.5f, maxWidth / 12))
|
TurnCard(t, minOf(maxHeight / t.rows, maxWidth / 12))
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
}
|
}
|
||||||
@@ -111,13 +112,12 @@ internal fun ClusterScreen(marginX: Int, marginY: Int, densityDpi: Int, clearAre
|
|||||||
}
|
}
|
||||||
return@BoxWithConstraints
|
return@BoxWithConstraints
|
||||||
}
|
}
|
||||||
// The card is about 8.5 units tall with two lines of directions, and 14 wide: fit both ways.
|
|
||||||
val unit = minOf(maxHeight / 8.5f, maxWidth / 14)
|
|
||||||
if (t == null) {
|
if (t == null) {
|
||||||
NowPlayingLine(Modifier.align(Alignment.Center))
|
NowPlayingLine(Modifier.align(Alignment.Center))
|
||||||
return@BoxWithConstraints
|
return@BoxWithConstraints
|
||||||
}
|
}
|
||||||
TurnCard(t, unit)
|
// The card is t.rows units tall and about 14 wide: fit both ways.
|
||||||
|
TurnCard(t, minOf(maxHeight / t.rows, maxWidth / 14))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,6 +187,9 @@ private fun drawsOnCluster(context: Context, component: ComponentName): Boolean
|
|||||||
private const val FEATURE_CLUSTER = "androidx.car.app.category.FEATURE_CLUSTER"
|
private const val FEATURE_CLUSTER = "androidx.car.app.category.FEATURE_CLUSTER"
|
||||||
private const val CLUSTER_SESSION = "gearslip-cluster"
|
private const val CLUSTER_SESSION = "gearslip-cluster"
|
||||||
|
|
||||||
|
/** The lane to be in, on the cockpit: green, like the main screen's header, against the black. */
|
||||||
|
private val LANE_FILL = Color(0xFF2F7D6D)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start, top, end and bottom padding around the card inside the visible picture. The car's own
|
* Start, top, end and bottom padding around the card inside the visible picture. The car's own
|
||||||
* insets win when they leave a usable area; a wide strip, where dials sit at both ends and a bar
|
* insets win when they leave a usable area; a wide strip, where dials sit at both ends and a bar
|
||||||
@@ -225,6 +228,20 @@ private fun TurnCard(t: ClusterTurn, unit: Dp) {
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (t.lanes.isNotEmpty()) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
LaneRoad(t.lanes, Modifier.width(unit * 10).height(unit * 2.4f), LANE_FILL, Color.White, Color.Black)
|
||||||
|
laneHint(t.lanes)?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
Modifier.padding(top = unit * 0.3f),
|
||||||
|
fontSize = with(density) { (unit * 0.8f).toSp() },
|
||||||
|
color = Color.White.copy(alpha = 0.85f),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (t.trip.isNotEmpty()) {
|
if (t.trip.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
t.trip,
|
t.trip,
|
||||||
@@ -256,7 +273,11 @@ private class ClusterTurn(
|
|||||||
val distance: String,
|
val distance: String,
|
||||||
val what: String,
|
val what: String,
|
||||||
val trip: String,
|
val trip: String,
|
||||||
|
val lanes: List<androidx.car.app.navigation.model.Lane>,
|
||||||
) {
|
) {
|
||||||
|
/** How many units tall the card is: two lines of directions, and the lanes when there are some. */
|
||||||
|
val rows: Float get() = if (lanes.isEmpty()) 8.5f else 12f
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun now(): ClusterTurn? {
|
fun now(): ClusterTurn? {
|
||||||
val turn = NavTurn.current() ?: return null
|
val turn = NavTurn.current() ?: return null
|
||||||
@@ -272,7 +293,7 @@ private class ClusterTurn(
|
|||||||
e.arrivalTimeAtDestination.clockTime(),
|
e.arrivalTimeAtDestination.clockTime(),
|
||||||
).filter { it.isNotEmpty() }.joinToString(" · ")
|
).filter { it.isNotEmpty() }.joinToString(" · ")
|
||||||
}.orEmpty()
|
}.orEmpty()
|
||||||
return ClusterTurn(step.maneuver?.icon, turn.distance.display(), what, trip)
|
return ClusterTurn(step.maneuver?.icon, turn.distance.display(), what, trip, step.lanes.orEmpty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.car.app.navigation.model.Lane
|
||||||
|
import androidx.car.app.navigation.model.LaneDirection
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/** The dark band the lanes sit on under the turn card's header, tinted toward the theme. */
|
||||||
|
@Composable
|
||||||
|
internal fun laneBandColor(): Color = lerp(Color(0xFF0E1417), MaterialTheme.colorScheme.primary, 0.14f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lanes before a manoeuvre as a dark band under the turn card's header: the road ahead in
|
||||||
|
* perspective, with the lane or lanes to be in filled with the header's colour.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun LaneBand(lanes: List<Lane>, modifier: Modifier = Modifier) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val band = laneBandColor()
|
||||||
|
LaneRoad(
|
||||||
|
lanes,
|
||||||
|
modifier.background(band).padding(horizontal = 16.dp, vertical = 10.dp).fillMaxWidth().height(52.dp),
|
||||||
|
scheme.primaryContainer,
|
||||||
|
scheme.onPrimaryContainer,
|
||||||
|
band,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which lanes to use, in words, on the same band: "Use the left lane". Nothing when it can't say. */
|
||||||
|
@Composable
|
||||||
|
internal fun LaneHintLine(lanes: List<Lane>, modifier: Modifier = Modifier) {
|
||||||
|
val hint = laneHint(lanes) ?: return
|
||||||
|
Text(
|
||||||
|
hint,
|
||||||
|
modifier.background(laneBandColor()).padding(start = 16.dp, end = 16.dp, bottom = 10.dp),
|
||||||
|
style = ChromeType.label,
|
||||||
|
color = Color.White,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The road ahead, narrowing toward the top: solid edges, dashed lines between lanes, the
|
||||||
|
* recommended lanes filled with [fill] and every lane's arrows drawn from its directions, the
|
||||||
|
* recommended ones in [onFill] and the rest faded toward [ground], the colour behind the road.
|
||||||
|
* The arrows are painted on the road, so they narrow with it. Lanes run left to right, as the app
|
||||||
|
* sends them.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun LaneRoad(lanes: List<Lane>, modifier: Modifier, fill: Color, onFill: Color, ground: Color, line: Color = Color.White) {
|
||||||
|
val named = lanes.map {
|
||||||
|
stringResource(if (it.recommended()) R.string.nav_lane_recommended else R.string.nav_lane)
|
||||||
|
}.joinToString(", ")
|
||||||
|
Canvas(modifier.semantics { contentDescription = named }) {
|
||||||
|
val n = lanes.size
|
||||||
|
if (n == 0) return@Canvas
|
||||||
|
val h = size.height
|
||||||
|
// Each lane at most a little wider than the road is tall, so two lanes don't fill a whole card.
|
||||||
|
val bottomWidth = minOf(size.width, h * 1.6f * n)
|
||||||
|
val topWidth = bottomWidth * 0.86f
|
||||||
|
fun width(y: Float) = topWidth + (bottomWidth - topWidth) * (y / h)
|
||||||
|
// The x of lane edge [edge] (0 to n, fractions for the middle of a lane) at height [y].
|
||||||
|
fun x(edge: Float, y: Float): Float = size.width / 2 - width(y) / 2 + width(y) * edge / n
|
||||||
|
lanes.forEachIndexed { i, lane ->
|
||||||
|
if (!lane.recommended()) return@forEachIndexed
|
||||||
|
drawPath(
|
||||||
|
Path().apply {
|
||||||
|
moveTo(x(i.toFloat(), 0f), 0f)
|
||||||
|
lineTo(x(i + 1f, 0f), 0f)
|
||||||
|
lineTo(x(i + 1f, h), h)
|
||||||
|
lineTo(x(i.toFloat(), h), h)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
fill,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val edge = 2.dp.toPx()
|
||||||
|
drawLine(line, Offset(x(0f, 0f), 0f), Offset(x(0f, h), h), edge)
|
||||||
|
drawLine(line, Offset(x(n.toFloat(), 0f), 0f), Offset(x(n.toFloat(), h), h), edge)
|
||||||
|
val dashes = PathEffect.dashPathEffect(floatArrayOf(h * 0.22f, h * 0.14f))
|
||||||
|
for (i in 1 until n) {
|
||||||
|
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 bottom = h * 0.9f
|
||||||
|
val half = minOf(bottomWidth / n * 0.34f, h * 0.4f)
|
||||||
|
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 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 ->
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which lanes to be in, in words, when the recommended ones sit together: the left or right
|
||||||
|
* lanes, the middle one, a lane counted from the nearer side, or any. Null when nothing is
|
||||||
|
* recommended or the lanes to use aren't next to each other.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun laneHint(lanes: List<Lane>): String? {
|
||||||
|
val n = lanes.size
|
||||||
|
val use = lanes.indices.filter { lanes[it].recommended() }
|
||||||
|
if (n < 2 || use.isEmpty()) return null
|
||||||
|
if (use.size == n) return stringResource(R.string.nav_lane_any)
|
||||||
|
if (use.last() - use.first() + 1 != use.size) return null
|
||||||
|
val resources = LocalContext.current.resources
|
||||||
|
val k = use.size
|
||||||
|
val only = use.first()
|
||||||
|
return when {
|
||||||
|
only == 0 -> resources.getQuantityString(R.plurals.nav_lane_left, k, k)
|
||||||
|
use.last() == n - 1 -> resources.getQuantityString(R.plurals.nav_lane_right, k, k)
|
||||||
|
k == 1 && n % 2 == 1 && only == n / 2 -> stringResource(R.string.nav_lane_middle)
|
||||||
|
k == 1 && only < n - 1 - only -> stringResource(R.string.nav_lane_from_left, only + 1)
|
||||||
|
k == 1 -> stringResource(R.string.nav_lane_from_right, n - only)
|
||||||
|
else -> stringResource(R.string.nav_lane_middle_lanes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Lane.recommended(): Boolean = directions.orEmpty().any { it.isRecommended }
|
||||||
@@ -30,7 +30,6 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -39,8 +38,8 @@ 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.layout.layoutId
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -49,8 +48,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.car.app.model.CarIcon
|
import androidx.car.app.model.CarIcon
|
||||||
import androidx.car.app.model.Template
|
import androidx.car.app.model.Template
|
||||||
import androidx.car.app.model.PlaceListMapTemplate
|
import androidx.car.app.model.PlaceListMapTemplate
|
||||||
import androidx.car.app.navigation.model.Lane
|
|
||||||
import androidx.car.app.navigation.model.LaneDirection
|
|
||||||
import androidx.car.app.navigation.model.MapTemplate
|
import androidx.car.app.navigation.model.MapTemplate
|
||||||
import androidx.car.app.navigation.model.MapWithContentTemplate
|
import androidx.car.app.navigation.model.MapWithContentTemplate
|
||||||
import androidx.car.app.navigation.model.NavigationTemplate
|
import androidx.car.app.navigation.model.NavigationTemplate
|
||||||
@@ -228,8 +225,11 @@ private fun NavigationBars(
|
|||||||
Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp),
|
Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
|
// The estimate always gets its full height; the turn card has what's left and
|
||||||
Spacer(Modifier.weight(1f))
|
// 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()) }
|
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,9 +267,10 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
|||||||
val step = info.currentStep ?: return
|
val step = info.currentStep ?: return
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
ChromeSurface(modifier) {
|
ChromeSurface(modifier) {
|
||||||
Column {
|
// On a short screen the hint goes first, then the next turn, then the lanes; never the turn.
|
||||||
|
FitColumn {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().background(scheme.primaryContainer).padding(16.dp),
|
Modifier.layoutId(0).fillMaxWidth().background(scheme.primaryContainer).padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
androidx.compose.runtime.CompositionLocalProvider(
|
androidx.compose.runtime.CompositionLocalProvider(
|
||||||
@@ -302,16 +303,22 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) {
|
// An app that sends only its own picture of the lanes gets it shown as it is.
|
||||||
LaneGuidance(step, Modifier.fillMaxWidth().heightIn(max = 56.dp))
|
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.
|
// Rare, and only where an arrow can't explain the junction: worth the card growing for.
|
||||||
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())
|
||||||
|
}
|
||||||
info.nextStep?.let { next ->
|
info.nextStep?.let { next ->
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
Modifier.layoutId(2).fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.nav_then), style = ChromeType.label, color = scheme.onSurfaceVariant)
|
Text(stringResource(R.string.nav_then), style = ChromeType.label, color = scheme.onSurfaceVariant)
|
||||||
@@ -328,6 +335,39 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A column that leaves whole parts out when they don't all fit, rather than cutting the last one
|
||||||
|
* in half. Each part's layoutId is its rank: the lowest is kept first and always shown, then each
|
||||||
|
* next one if there's still room for it. What's kept stays in its written order.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FitColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||||
|
androidx.compose.ui.layout.Layout(content, modifier) { measurables, constraints ->
|
||||||
|
val loose = constraints.copy(minHeight = 0, maxHeight = androidx.compose.ui.unit.Constraints.Infinity)
|
||||||
|
val parts = measurables.map { it.measure(loose) }
|
||||||
|
val ranks = measurables.map { it.layoutId as? Int ?: 0 }
|
||||||
|
val kept = BooleanArray(parts.size)
|
||||||
|
var room = if (constraints.hasBoundedHeight) constraints.maxHeight else Int.MAX_VALUE
|
||||||
|
parts.indices.sortedBy { ranks[it] }.forEachIndexed { order, i ->
|
||||||
|
if (order == 0 || parts[i].height <= room) {
|
||||||
|
kept[i] = true
|
||||||
|
room -= parts[i].height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val width = (parts.maxOfOrNull { it.width } ?: 0).coerceIn(constraints.minWidth, constraints.maxWidth)
|
||||||
|
val height = parts.indices.filter { kept[it] }.sumOf { parts[it].height }
|
||||||
|
layout(width, height.coerceAtLeast(constraints.minHeight)) {
|
||||||
|
var y = 0
|
||||||
|
parts.forEachIndexed { i, part ->
|
||||||
|
if (kept[i]) {
|
||||||
|
part.place(0, y)
|
||||||
|
y += part.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The zoomed-in intersection an app draws for a junction too complex for an arrow to explain. */
|
/** The zoomed-in intersection an app draws for a junction too complex for an arrow to explain. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
|
private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
|
||||||
@@ -340,63 +380,6 @@ private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Which lane to be in before the manoeuvre, either as the app's own picture ([Step.getLanesImage])
|
|
||||||
* or as a row of arrows built from its structured lane data - whichever it sent. The recommended
|
|
||||||
* lane, or lanes, stand out in the theme's own colour; the rest sit on the same muted, solid tile
|
|
||||||
* everything else uses, never dimmed by transparency.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun LaneGuidance(step: Step, modifier: Modifier = Modifier) {
|
|
||||||
step.lanesImage?.let {
|
|
||||||
JunctionImage(it, modifier)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val lanes = step.lanes.orEmpty()
|
|
||||||
if (lanes.isEmpty()) return
|
|
||||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
lanes.forEach { lane -> LaneTile(lane) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LaneTile(lane: Lane) {
|
|
||||||
val directions = lane.directions.orEmpty()
|
|
||||||
val shown = directions.firstOrNull { it.isRecommended } ?: directions.firstOrNull()
|
|
||||||
val recommended = shown?.isRecommended == true
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(
|
|
||||||
if (recommended) MaterialTheme.colorScheme.primaryContainer
|
|
||||||
else MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.KeyboardArrowUp,
|
|
||||||
contentDescription = stringResource(if (recommended) R.string.nav_lane_recommended else R.string.nav_lane),
|
|
||||||
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The one arrow glyph this app draws, turned to stand in for every shape a lane can permit. */
|
|
||||||
private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
|
|
||||||
LaneDirection.SHAPE_SLIGHT_RIGHT -> 30f
|
|
||||||
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
|
|
||||||
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
|
|
||||||
LaneDirection.SHAPE_U_TURN_RIGHT -> 170f
|
|
||||||
LaneDirection.SHAPE_SLIGHT_LEFT -> -30f
|
|
||||||
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
|
|
||||||
LaneDirection.SHAPE_SHARP_LEFT -> -135f
|
|
||||||
LaneDirection.SHAPE_U_TURN_LEFT -> -170f
|
|
||||||
else -> 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The trip at a glance, stacked: time left on top, then the clock time it ends at and the
|
* 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
|
* distance, with Gearslip's own stop button beside them. The close button is Gearslip's, not the
|
||||||
|
|||||||
@@ -737,6 +737,19 @@
|
|||||||
<!-- -->
|
<!-- -->
|
||||||
<string name="nav_lane_recommended">Recommended lane</string>
|
<string name="nav_lane_recommended">Recommended lane</string>
|
||||||
<string name="nav_lane">Lane</string>
|
<string name="nav_lane">Lane</string>
|
||||||
|
<plurals name="nav_lane_left">
|
||||||
|
<item quantity="one">Use the left lane</item>
|
||||||
|
<item quantity="other">Use the left %d lanes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="nav_lane_right">
|
||||||
|
<item quantity="one">Use the right lane</item>
|
||||||
|
<item quantity="other">Use the right %d lanes</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="nav_lane_middle">Use the middle lane</string>
|
||||||
|
<string name="nav_lane_middle_lanes">Use the middle lanes</string>
|
||||||
|
<string name="nav_lane_from_left">Use lane %1$d from the left</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_arrive">Arrive %1$s</string>
|
<string name="nav_arrive">Arrive %1$s</string>
|
||||||
|
|
||||||
<!-- Car app screens -->
|
<!-- Car app screens -->
|
||||||
|
|||||||
Reference in new issue
Block a user