diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterScreen.kt index 0884b05..6ddf3b8 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -103,7 +104,7 @@ internal fun ClusterScreen(marginX: Int, marginY: Int, densityDpi: Int, clearAre if (t != null) { BoxWithConstraints(Modifier.weight(0.42f).fillMaxHeight(), contentAlignment = Alignment.CenterStart) { // 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)) } @@ -111,13 +112,12 @@ internal fun ClusterScreen(marginX: Int, marginY: Int, densityDpi: Int, clearAre } 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) { NowPlayingLine(Modifier.align(Alignment.Center)) 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 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 * 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, ) } + 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()) { Text( t.trip, @@ -256,7 +273,11 @@ private class ClusterTurn( val distance: String, val what: String, val trip: String, + val lanes: List, ) { + /** 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 { fun now(): ClusterTurn? { val turn = NavTurn.current() ?: return null @@ -272,7 +293,7 @@ private class ClusterTurn( e.arrivalTimeAtDestination.clockTime(), ).filter { it.isNotEmpty() }.joinToString(" ยท ") }.orEmpty() - return ClusterTurn(step.maneuver?.icon, turn.distance.display(), what, trip) + return ClusterTurn(step.maneuver?.icon, turn.distance.display(), what, trip, step.lanes.orEmpty()) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/LaneRoad.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/LaneRoad.kt new file mode 100644 index 0000000..68086ac --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/LaneRoad.kt @@ -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, 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, 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, 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): 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 } 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 8a9c53f..b72c90a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt @@ -30,7 +30,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -39,8 +38,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layoutId import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale 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.Template 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.MapWithContentTemplate 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), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) } - Spacer(Modifier.weight(1f)) + // 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()) } } } @@ -267,9 +267,10 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) { val step = info.currentStep ?: return val scheme = MaterialTheme.colorScheme ChromeSurface(modifier) { - Column { + // On a short screen the hint goes first, then the next turn, then the lanes; never the turn. + FitColumn { Column( - Modifier.fillMaxWidth().background(scheme.primaryContainer).padding(16.dp), + Modifier.layoutId(0).fillMaxWidth().background(scheme.primaryContainer).padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { androidx.compose.runtime.CompositionLocalProvider( @@ -302,16 +303,22 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) { overflow = TextOverflow.Ellipsis, ) } - if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) { - LaneGuidance(step, Modifier.fillMaxWidth().heightIn(max = 56.dp)) + // 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) } } } + // 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 -> Row( - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + 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) @@ -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. */ @Composable 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 * distance, with Gearslip's own stop button beside them. The close button is Gearslip's, not the diff --git a/gearslip/src/main/res/values/strings.xml b/gearslip/src/main/res/values/strings.xml index 5b03bdf..6048505 100644 --- a/gearslip/src/main/res/values/strings.xml +++ b/gearslip/src/main/res/values/strings.xml @@ -737,6 +737,19 @@ Recommended lane Lane + + Use the left lane + Use the left %d lanes + + + Use the right lane + Use the right %d lanes + + Use the middle lane + Use the middle lanes + Use lane %1$d from the left + Use lane %1$d from the right + Any lane Arrive %1$s