mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
navigation: directions in short bars, measured so apps keep their map clear of them
OsmAnd centred its arrow across the whole map because we only told it about the arrival time, and Waze's speedometer sat under the map buttons. The turn is now one short bar across the top and the arrival time one line along the bottom; both bars and the button column are measured where they land and left out of the area the app is told it can see.
This commit is contained in:
1 parent
4e71689f8e
commit
d78585fda1
4 files changed
+200
-76
No files matched your search
@@ -34,6 +34,8 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
@@ -59,13 +61,17 @@ fun CarAppStage(
|
|||||||
) {
|
) {
|
||||||
val template by connection.template.collectAsStateWithLifecycle()
|
val template by connection.template.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val cover = remember { ChromeCover() }
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
// The app's own drawing goes underneath; everything else is the host's rendering of
|
// The app's own drawing goes underneath; everything else is the host's rendering of
|
||||||
// the template it described, laid over the top.
|
// the template it described, laid over the top.
|
||||||
if (status.surfaceRequested) {
|
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover) {
|
||||||
AppSurface(connection, frame, template, Modifier.fillMaxSize())
|
if (status.surfaceRequested) {
|
||||||
|
AppSurface(connection, frame, template, Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR))
|
||||||
}
|
}
|
||||||
TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR))
|
|
||||||
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
|
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,13 +90,16 @@ private fun AppSurface(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var size by remember { mutableStateOf(0 to 0) }
|
var size by remember { mutableStateOf(0 to 0) }
|
||||||
|
var bounds by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
|
||||||
|
val cover = LocalChromeCover.current
|
||||||
|
|
||||||
// The app lays its map out around whatever the host covers, so a template change moves the
|
// The app lays its map out around whatever the host covers, so a template change or a card
|
||||||
// visible area even though the surface itself hasn't changed.
|
// changing size moves the visible area even though the surface itself hasn't changed.
|
||||||
LaunchedEffect(template, size) {
|
val (width, height) = size
|
||||||
val (width, height) = size
|
val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) }
|
||||||
|
LaunchedEffect(template, size, measured) {
|
||||||
if (width > 0 && height > 0) {
|
if (width > 0 && height > 0) {
|
||||||
val visible = visibleAreaFor(template, width, height)
|
val visible = visibleAreaFor(template, width, height, measured)
|
||||||
connection.reportAreas(visible = visible, stable = visible)
|
connection.reportAreas(visible = visible, stable = visible)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +110,9 @@ private fun AppSurface(
|
|||||||
// new one on it before releasing the old: the old display's disconnect then takes the new one's
|
// new one on it before releasing the old: the old display's disconnect then takes the new one's
|
||||||
// connection with it and the map freezes for good. The nav bar hiding for the search keyboard
|
// connection with it and the map freezes for good. The nav bar hiding for the search keyboard
|
||||||
// and the lyrics pane opening both resize this area.
|
// and the lyrics pane opening both resize this area.
|
||||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier) {
|
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||||
|
modifier.onGloballyPositioned { bounds = it.boundsInWindow() },
|
||||||
|
) {
|
||||||
val epoch by connection.surfaceEpoch.collectAsState()
|
val epoch by connection.surfaceEpoch.collectAsState()
|
||||||
androidx.compose.runtime.key(constraints.maxWidth, constraints.maxHeight, epoch) {
|
androidx.compose.runtime.key(constraints.maxWidth, constraints.maxHeight, epoch) {
|
||||||
SurfaceHost(connection, frame, onSize = { size = it }, Modifier.fillMaxSize())
|
SurfaceHost(connection, frame, onSize = { size = it }, Modifier.fillMaxSize())
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ package app.seb3thehacker.gearslip.car
|
|||||||
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.geometry.Rect as ComposeRect
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -10,6 +17,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -86,11 +94,13 @@ fun TemplateChrome(template: Template?, modifier: Modifier = Modifier) {
|
|||||||
* How much of the surface Gearslip's chrome covers, so the app can keep what matters clear of
|
* How much of the surface Gearslip's chrome covers, so the app can keep what matters clear of
|
||||||
* it. Expressed as the visible rectangle within a [width] x [height] surface.
|
* it. Expressed as the visible rectangle within a [width] x [height] surface.
|
||||||
*
|
*
|
||||||
|
* Navigation's cards are measured where they land ([measured], from [ChromeCover]); an app
|
||||||
|
* centres its position marker in this rectangle, so a guess puts the marker under the card.
|
||||||
* A template that isn't map-backed covers the surface completely, and reporting an empty
|
* A template that isn't map-backed covers the surface completely, and reporting an empty
|
||||||
* rectangle is how the app is told to stop bothering.
|
* rectangle is how the app is told to stop bothering.
|
||||||
*/
|
*/
|
||||||
fun visibleAreaFor(template: Template?, width: Int, height: Int): Rect = when (template) {
|
fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect? = null): Rect = when (template) {
|
||||||
is NavigationTemplate -> Rect(0, 0, width, (height * (1f - ESTIMATE_FRACTION)).toInt())
|
is NavigationTemplate -> measured ?: Rect(0, 0, width, height)
|
||||||
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
|
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
|
||||||
is RoutePreviewNavigationTemplate, is PlaceListMapTemplate,
|
is RoutePreviewNavigationTemplate, is PlaceListMapTemplate,
|
||||||
-> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, height)
|
-> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, height)
|
||||||
@@ -98,77 +108,158 @@ fun visibleAreaFor(template: Template?, width: Int, height: Int): Rect = when (t
|
|||||||
else -> Rect(0, 0, 0, 0)
|
else -> Rect(0, 0, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The side of the map a piece of chrome sits against, and so the side it shrinks the visible area from. */
|
||||||
|
internal enum class CoverEdge { TOP, BOTTOM, END }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the chrome over the map actually landed, in window pixels. The map buttons count even
|
||||||
|
* while faded out, or whatever an app draws in a corner (Waze's speedometer) is under them the
|
||||||
|
* moment they come back.
|
||||||
|
*/
|
||||||
|
internal class ChromeCover {
|
||||||
|
private val pieces = mutableStateMapOf<String, Pair<CoverEdge, ComposeRect>>()
|
||||||
|
|
||||||
|
fun put(key: String, edge: CoverEdge, bounds: ComposeRect) {
|
||||||
|
val piece = edge to bounds
|
||||||
|
if (pieces[key] != piece) pieces[key] = piece
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(key: String) {
|
||||||
|
pieces.remove(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is left of a [width] x [height] surface at [surface] (window pixels), in surface pixels. */
|
||||||
|
fun visibleWithin(surface: ComposeRect, width: Int, height: Int): Rect {
|
||||||
|
var left = 0f
|
||||||
|
var top = 0f
|
||||||
|
var right = width.toFloat()
|
||||||
|
var bottom = height.toFloat()
|
||||||
|
pieces.values.forEach { (edge, b) ->
|
||||||
|
when (edge) {
|
||||||
|
CoverEdge.TOP -> top = maxOf(top, b.bottom - surface.top)
|
||||||
|
CoverEdge.BOTTOM -> bottom = minOf(bottom, b.top - surface.top)
|
||||||
|
CoverEdge.END -> right = minOf(right, b.left - surface.left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Never hand an app nothing to draw in, however the cards end up.
|
||||||
|
if (right - left < width / 4f) { left = 0f; right = width.toFloat() }
|
||||||
|
if (bottom - top < height / 4f) { top = 0f; bottom = height.toFloat() }
|
||||||
|
return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val LocalChromeCover = compositionLocalOf<ChromeCover?> { null }
|
||||||
|
|
||||||
|
/** Records where this lands, padding included, until it leaves the screen. */
|
||||||
|
@Composable
|
||||||
|
private fun Modifier.covers(key: String, edge: CoverEdge): Modifier {
|
||||||
|
val cover = LocalChromeCover.current ?: return this
|
||||||
|
DisposableEffect(cover, key) { onDispose { cover.remove(key) } }
|
||||||
|
return onGloballyPositioned { cover.put(key, edge, it.boundsInWindow()) }
|
||||||
|
}
|
||||||
|
|
||||||
// --- navigation ------------------------------------------------------------------------------
|
// --- navigation ------------------------------------------------------------------------------
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
||||||
val pan by CarServices.nav.panMode.collectAsState()
|
val pan by CarServices.nav.panMode.collectAsState()
|
||||||
Box(modifier.fillMaxWidth()) {
|
// Pan mode clears the map for looking around: only the buttons stay.
|
||||||
if (!pan) (template.navigationInfo as? RoutingInfo)?.let {
|
val routing = (template.navigationInfo as? RoutingInfo)?.takeUnless { pan }
|
||||||
RoutingCard(it, Modifier.align(Alignment.TopStart).padding(12.dp).width(320.dp))
|
val estimate = template.destinationTravelEstimate?.takeUnless { pan }
|
||||||
|
NavigationBars(template, routing, estimate, modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The turn in one short bar across the top, the arrival time in another along the bottom, so the
|
||||||
|
* map keeps its full width. Neither grows past [NAV_BAR_MAX_WIDTH]. The map buttons sit between them on the right.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavigationBars(
|
||||||
|
template: NavigationTemplate,
|
||||||
|
routing: RoutingInfo?,
|
||||||
|
estimate: TravelEstimate?,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
val pan by CarServices.nav.panMode.collectAsState()
|
||||||
|
Column(modifier.fillMaxSize()) {
|
||||||
|
routing?.let {
|
||||||
|
RoutingBar(
|
||||||
|
it,
|
||||||
|
Modifier.covers("nav-top", CoverEdge.TOP).padding(start = 12.dp, top = 12.dp, end = 12.dp)
|
||||||
|
.widthIn(max = NAV_BAR_MAX_WIDTH).fillMaxWidth(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
RightEdgeControls(
|
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
if (pan) null else template.actionStrip,
|
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||||
template.mapActionStrip,
|
estimate?.let {
|
||||||
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
TravelEstimateBar(
|
||||||
)
|
it,
|
||||||
if (!pan) template.destinationTravelEstimate?.let {
|
Modifier.align(Alignment.BottomStart).covers("nav-bottom", CoverEdge.BOTTOM).padding(12.dp)
|
||||||
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart).padding(12.dp))
|
.widthIn(max = NAV_BAR_MAX_WIDTH),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RightEdgeControls(
|
||||||
|
if (pan) null else template.actionStrip,
|
||||||
|
template.mapActionStrip,
|
||||||
|
Modifier.padding(12.dp),
|
||||||
|
// Only the column: the app's own buttons are a wide row up top, over the map's
|
||||||
|
// edge rather than where a speedometer or the like would go.
|
||||||
|
mapActionsModifier = Modifier.covers("nav-buttons", CoverEdge.END),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The manoeuvre card: what to do next, how far away it is, and onto what road. */
|
/** The manoeuvre in one row: arrow, distance, what to do, onto what road, and lanes or the next turn. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
private fun RoutingBar(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||||
if (info.isLoading) {
|
if (info.isLoading) {
|
||||||
ChromeSurface(modifier) {
|
ChromeSurface(modifier) {
|
||||||
Text("Finding a route…", Modifier.padding(16.dp), style = ChromeType.title)
|
Text("Finding a route…", Modifier.padding(horizontal = 16.dp, vertical = 12.dp), style = ChromeType.title)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val step = info.currentStep ?: return
|
val step = info.currentStep ?: return
|
||||||
ChromeSurface(modifier) {
|
ChromeSurface(modifier) {
|
||||||
Column(Modifier.padding(14.dp)) {
|
Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
CarGlyph(step.maneuver?.icon, Modifier.size(44.dp))
|
CarGlyph(step.maneuver?.icon, Modifier.size(40.dp))
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
val distance = info.currentDistance.display()
|
||||||
val distance = info.currentDistance.display()
|
if (distance.isNotEmpty()) {
|
||||||
if (distance.isNotEmpty()) {
|
Text(distance, style = ChromeType.headline, maxLines = 1)
|
||||||
Text(distance, style = ChromeType.headline)
|
Spacer(Modifier.width(14.dp))
|
||||||
}
|
|
||||||
Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
}
|
||||||
}
|
Column(Modifier.weight(1f)) {
|
||||||
val road = step.road.text()
|
Text(step.cue.text(), style = ChromeType.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
if (road.isNotEmpty()) {
|
val road = step.road.text()
|
||||||
Text(
|
if (road.isNotEmpty()) {
|
||||||
road,
|
Text(
|
||||||
style = ChromeType.body,
|
road,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
style = ChromeType.body,
|
||||||
maxLines = 1,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
overflow = TextOverflow.Ellipsis,
|
maxLines = 1,
|
||||||
)
|
overflow = TextOverflow.Ellipsis,
|
||||||
}
|
)
|
||||||
// Neither is sent by every app - most say only "turn left" and trust the driver to
|
}
|
||||||
// find the lane themselves - so both are drawn only when there is something to draw.
|
}
|
||||||
info.junctionImage?.let { junction ->
|
val next = info.nextStep
|
||||||
Spacer(Modifier.height(10.dp))
|
if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) {
|
||||||
JunctionImage(junction)
|
LaneGuidance(step, Modifier.padding(start = 12.dp).widthIn(max = 200.dp).heightIn(max = 48.dp))
|
||||||
}
|
} else if (next != null) {
|
||||||
LaneGuidance(step, Modifier.padding(top = 10.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
info.nextStep?.let { next ->
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
|
CarGlyph(next.maneuver?.icon, Modifier.size(24.dp))
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(next.cue.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Rare, and only where an arrow can't explain the junction: worth the bar growing for.
|
||||||
|
info.junctionImage?.let { junction ->
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
JunctionImage(junction)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,9 +335,8 @@ private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The trip at a glance: time and distance left, the clock time it ends at, and - beside them,
|
* The trip at a glance: time and distance left, the clock time it ends at, and - beside them,
|
||||||
* where a thumb reaches - the app's own way to stop navigating. A floating card rather than a
|
* where a thumb reaches - the app's own way to stop navigating. One short line along the bottom,
|
||||||
* full-width bar, so it reads as one thing beside the manoeuvre card above it instead of a strip
|
* stopping short of the map buttons. The close button is Gearslip's own, not the app's: an
|
||||||
* that happens to hold three numbers. The close button is Gearslip's own, not the app's: an
|
|
||||||
* actionStrip's actions carry no flag saying which one (if any) means "stop navigating" - the
|
* actionStrip's actions carry no flag saying which one (if any) means "stop navigating" - the
|
||||||
* first button here turned out to be a mute toggle, not an exit - so this leaves the app the way
|
* first button here turned out to be a mute toggle, not an exit - so this leaves the app the way
|
||||||
* going Home already does, rather than guess at the app's own controls and risk getting it wrong.
|
* going Home already does, rather than guess at the app's own controls and risk getting it wrong.
|
||||||
@@ -265,7 +355,7 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod
|
|||||||
trip.takeIf { it.isNotEmpty() },
|
trip.takeIf { it.isNotEmpty() },
|
||||||
).joinToString(" • ")
|
).joinToString(" • ")
|
||||||
|
|
||||||
// The same solid card the manoeuvre card above it uses - one surface for the whole map
|
// The same solid card the manoeuvre bar above it uses - one surface for the whole map
|
||||||
// chrome, rather than this one spot styled on its own as a bar over the map.
|
// chrome, rather than this one spot styled on its own as a bar over the map.
|
||||||
ChromeSurface(modifier, shadowElevation = 8.dp) {
|
ChromeSurface(modifier, shadowElevation = 8.dp) {
|
||||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||||
@@ -288,17 +378,23 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod
|
|||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
Column {
|
if (time.isNotEmpty()) {
|
||||||
if (time.isNotEmpty()) {
|
Text(
|
||||||
Text(
|
time,
|
||||||
time,
|
style = ChromeType.headline,
|
||||||
style = ChromeType.headline,
|
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
|
||||||
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (detail.isNotEmpty()) {
|
if (detail.isNotEmpty()) {
|
||||||
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Spacer(Modifier.width(16.dp))
|
||||||
}
|
Text(
|
||||||
|
detail,
|
||||||
|
style = ChromeType.body,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,8 +550,9 @@ private fun ListPane(
|
|||||||
/** Fraction of the surface the content pane covers beside a map. */
|
/** Fraction of the surface the content pane covers beside a map. */
|
||||||
private const val CONTENT_PANE_FRACTION = 0.42f
|
private const val CONTENT_PANE_FRACTION = 0.42f
|
||||||
|
|
||||||
/** Fraction of the surface the travel-estimate bar covers along the bottom. */
|
|
||||||
private const val ESTIMATE_FRACTION = 0.14f
|
/** Past this the bars only spread the same words further apart, across a map left with no music beside it. */
|
||||||
|
private val NAV_BAR_MAX_WIDTH = 460.dp
|
||||||
|
|
||||||
/** Search, sign-in and the like are laid out for a whole column, so they keep one. */
|
/** Search, sign-in and the like are laid out for a whole column, so they keep one. */
|
||||||
private fun Template?.needsFullHeight() =
|
private fun Template?.needsFullHeight() =
|
||||||
|
|||||||
@@ -222,6 +222,8 @@ internal fun RightEdgeControls(
|
|||||||
actionStrip: ActionStrip?,
|
actionStrip: ActionStrip?,
|
||||||
mapActionStrip: ActionStrip?,
|
mapActionStrip: ActionStrip?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
/** For the stack of map buttons alone, without the row of the app's own above it. */
|
||||||
|
mapActionsModifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||||
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||||
@@ -263,7 +265,15 @@ internal fun RightEdgeControls(
|
|||||||
actions.forEach { ActionButton(it) }
|
actions.forEach { ActionButton(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mapActions.forEach { ActionButton(it) }
|
if (mapActions.isNotEmpty()) {
|
||||||
|
Column(
|
||||||
|
mapActionsModifier,
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
mapActions.forEach { ActionButton(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ class CarAppConnection(private val context: Context) {
|
|||||||
private var navigationManager: INavigationManager? = null
|
private var navigationManager: INavigationManager? = null
|
||||||
/** Main thread only, both of these - the handoff is a race between them either way round. */
|
/** Main thread only, both of these - the handoff is a race between them either way round. */
|
||||||
private var surfaceCallback: ISurfaceCallback? = null
|
private var surfaceCallback: ISurfaceCallback? = null
|
||||||
|
|
||||||
|
/** Last visible and stable areas the UI reported, kept for the next surface. */
|
||||||
|
private var areas: Pair<Rect, Rect>? = null
|
||||||
private var lent: Lent? = null
|
private var lent: Lent? = null
|
||||||
|
|
||||||
private var binding: ServiceConnection? = null
|
private var binding: ServiceConnection? = null
|
||||||
@@ -552,9 +555,11 @@ class CarAppConnection(private val context: Context) {
|
|||||||
// The app draws for the whole surface; the visible area is what isn't covered by host
|
// The app draws for the whole surface; the visible area is what isn't covered by host
|
||||||
// chrome, and the stable area is the part that never gets covered. Both depend on the
|
// chrome, and the stable area is the part that never gets covered. Both depend on the
|
||||||
// template (see visibleAreaFor) and are reported by the UI via [reportAreas] once it
|
// template (see visibleAreaFor) and are reported by the UI via [reportAreas] once it
|
||||||
// has measured. Until then the whole surface is fair game.
|
// has measured. A fresh surface the same size keeps the last ones, since the UI only
|
||||||
|
// reports again when they change; until then the whole surface is fair game.
|
||||||
val whole = Rect(0, 0, surface.width, surface.height)
|
val whole = Rect(0, 0, surface.width, surface.height)
|
||||||
reportAreas(whole, whole)
|
val last = areas?.takeIf { (visible, stable) -> whole.contains(visible) && whole.contains(stable) }
|
||||||
|
reportAreas(last?.first ?: whole, last?.second ?: whole)
|
||||||
|
|
||||||
GearslipLog.i(
|
GearslipLog.i(
|
||||||
"host: lent the app a ${surface.width}x${surface.height} @${surface.dpi}dpi surface"
|
"host: lent the app a ${surface.width}x${surface.height} @${surface.dpi}dpi surface"
|
||||||
@@ -575,6 +580,7 @@ class CarAppConnection(private val context: Context) {
|
|||||||
|
|
||||||
/** Tells the app which part of its surface the driver can actually see. */
|
/** Tells the app which part of its surface the driver can actually see. */
|
||||||
fun reportAreas(visible: Rect, stable: Rect) {
|
fun reportAreas(visible: Rect, stable: Rect) {
|
||||||
|
areas = visible to stable
|
||||||
val callback = surfaceCallback ?: return
|
val callback = surfaceCallback ?: return
|
||||||
runCatching { callback.onStableAreaChanged(stable, noop("onStableAreaChanged")) }
|
runCatching { callback.onStableAreaChanged(stable, noop("onStableAreaChanged")) }
|
||||||
runCatching { callback.onVisibleAreaChanged(visible, noop("onVisibleAreaChanged")) }
|
runCatching { callback.onVisibleAreaChanged(visible, noop("onVisibleAreaChanged")) }
|
||||||
|
|||||||
Reference in new issue
Block a user