car: each app screen uses its own connection, with a plain title bar and a close button

Back and the header icon in an app opened from the launcher went to the map app.
The bar above it showed the template class and draw state. Map buttons rest
faintly instead of vanishing, and place cards share the navigation width.
This commit is contained in:
Seb3thehacker committed 2026-10-24 14:24:28 -07:00
1 parent 8a6c358257
commit d90c39f50a
4 files changed
+62 -40

No files matched your search

@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -38,6 +39,7 @@ import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.filled.Close
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.host.CarAppConnection
@@ -67,7 +69,7 @@ fun CarAppStage(
Box(Modifier.fillMaxSize()) {
// The app's own drawing goes underneath; everything else is the host's rendering of
// the template it described, laid over the top.
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover) {
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
if (status.surfaceRequested) {
AppSurface(connection, frame, template, Modifier.fillMaxSize())
}
@@ -108,7 +110,7 @@ private fun AppSurface(
LaunchedEffect(template, size, measured, measuredStable) {
if (width > 0 && height > 0) {
val visible = visibleAreaFor(template, width, height, measured)
val stable = if (template is androidx.car.app.navigation.model.NavigationTemplate) {
val stable = if (!isContentTemplate(template)) {
visibleAreaFor(template, width, height, measuredStable)
} else {
visible
@@ -213,28 +215,31 @@ private fun ChromeBar(
onDisconnect: () -> Unit,
modifier: Modifier = Modifier,
) {
// The app's name and a way out, nothing else: which template is showing, or whether the
// app has drawn yet, is for the log, not the driver.
Surface(
color = Color.Black.copy(alpha = 0.55f),
contentColor = Color.White,
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
modifier = modifier.fillMaxWidth().height(CHROME_BAR),
) {
Row(
Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
status.app ?: "Car app",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
val detail = buildList {
status.template?.let { add(it) }
add(if (status.surfaceAttached) "drawing" else "waiting for the app")
}.joinToString(" - ")
Text(detail, style = MaterialTheme.typography.bodySmall)
}
GsButton(onClick = onDisconnect, tone = GsTone.Tonal) { Text("Stop") }
Text(
status.app ?: "Car app",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
GsIconButton(
androidx.compose.material.icons.Icons.Filled.Close,
"Close ${status.app ?: "the app"}",
onDisconnect,
size = 36.dp,
iconSize = 20.dp,
shape = androidx.compose.foundation.shape.CircleShape,
)
}
}
}
@@ -244,3 +249,6 @@ private val CHROME_BAR = 44.dp
/** Slower than this when the finger lifts and it was a drag that stopped, not a fling. */
private const val FLING_MIN_PX_S = 400f
/** The app connection a template on screen belongs to: the map app's, or the one opened from the launcher. */
internal val LocalCarAppConnection = androidx.compose.runtime.staticCompositionLocalOf { CarServices.nav }
@@ -103,7 +103,7 @@ fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect?
is NavigationTemplate -> measured ?: Rect(0, 0, width, height)
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
is RoutePreviewNavigationTemplate, is PlaceListMapTemplate,
-> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, measured?.bottom ?: height)
-> measured ?: Rect((width * CARD_COLUMN_FRACTION).toInt(), 0, width, height)
null -> Rect(0, 0, width, height)
else -> Rect(0, 0, 0, 0)
}
@@ -173,7 +173,8 @@ internal fun Modifier.covers(key: String, edge: CoverEdge): Modifier {
@Composable
private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
val pan by CarServices.nav.panMode.collectAsState()
val connection = LocalCarAppConnection.current
val pan by connection.panMode.collectAsState()
// Pan mode clears the map for looking around: only the buttons stay.
val routing = (template.navigationInfo as? RoutingInfo)?.takeUnless { pan }
val estimate = template.destinationTravelEstimate?.takeUnless { pan }
@@ -193,9 +194,10 @@ private fun NavigationBars(
estimate: TravelEstimate?,
modifier: Modifier,
) {
val pan by CarServices.nav.panMode.collectAsState()
val connection = LocalCarAppConnection.current
val pan by connection.panMode.collectAsState()
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) {
val column = (maxWidth * NAV_COLUMN_FRACTION).coerceIn(NAV_COLUMN_MIN, NAV_COLUMN_MAX)
val column = cardColumnWidth(maxWidth)
Row(Modifier.fillMaxSize()) {
if (routing != null || estimate != null) {
Column(
@@ -373,6 +375,7 @@ private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
*/
@Composable
private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Modifier) {
val connection = LocalCarAppConnection.current
val time = remainingTime(estimate.remainingTimeSeconds)
val distance = estimate.remainingDistance.display()
val arrival = estimate.arrivalTimeAtDestination.clockTime()
@@ -411,7 +414,7 @@ private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Mo
GsIconButton(
Icons.Filled.Close,
"Stop navigating",
{ CarServices.nav.stopNavigating() },
{ connection.stopNavigating() },
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
size = 52.dp,
iconSize = 26.dp,
@@ -438,16 +441,18 @@ private fun MapPaneLayout(
fullHeight: Boolean = false,
pane: @Composable () -> Unit,
) {
val connection = LocalCarAppConnection.current
// Pan mode hands the whole screen to the map, leaving only its own controls.
val pan by CarServices.nav.panMode.collectAsState()
val pan by connection.panMode.collectAsState()
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
if (!pan) {
ChromeSurface(
Modifier
.align(Alignment.TopStart)
.covers("map-pane", CoverEdge.START)
.padding(12.dp)
.width(maxOf(maxWidth * CONTENT_PANE_FRACTION - 12.dp, 232.dp))
.width(cardColumnWidth(maxWidth) - 24.dp)
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp),
shadowElevation = 8.dp,
@@ -568,14 +573,16 @@ private fun ListPane(
}
}
/** Fraction of the surface the content pane covers beside a map. */
private const val CONTENT_PANE_FRACTION = 0.42f
/** Past this the bars only spread the same words further apart, across a map left with no music beside it. */
private val NAV_COLUMN_MIN = 280.dp
private val NAV_COLUMN_MAX = 420.dp
private const val NAV_COLUMN_FRACTION = 0.38f
/**
* The width of the cards beside the map, the same for a place card and for navigation so the
* map doesn't jump sideways when a route starts.
*/
internal fun cardColumnWidth(maxWidth: androidx.compose.ui.unit.Dp): androidx.compose.ui.unit.Dp =
(maxWidth * CARD_COLUMN_FRACTION).coerceIn(280.dp, 440.dp).coerceAtMost(maxWidth)
private const val CARD_COLUMN_FRACTION = 0.4f
/** Search, sign-in and the like are laid out for a whole column, so they keep one. */
private fun Template?.needsFullHeight() =
@@ -517,7 +517,8 @@ private val sectionCache = android.util.LruCache<String, List<Item>>(64)
*/
@Composable
private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier, showActionsInHeader: Boolean = true, tabId: String = "") {
val appPackage = CarServices.nav.appPackage.orEmpty()
val connection = LocalCarAppConnection.current
val appPackage = connection.appPackage.orEmpty()
ContentSurface(modifier) {
// The app's own actions (Spotify's search and Liked Songs shortcut, for two) come from
// template.actions, not the header - but a bottom-of-content row is wrong for a driver
@@ -218,6 +218,9 @@ internal fun headerTitle(header: Header?, fallback: String): String =
* actions in a row along the top, its map controls stacked under them. Keeping them in one
* column is what stops them colliding on a screen only 480px tall.
*/
/** How visible the map buttons stay once they fade: enough to know they're there. */
private const val RESTING_ALPHA = 0.35f
@Composable
internal fun RightEdgeControls(
actionStrip: ActionStrip?,
@@ -226,22 +229,23 @@ internal fun RightEdgeControls(
/** For the stack of map buttons alone, without the row of the app's own above it. */
mapActionsModifier: Modifier = Modifier,
) {
val connection = LocalCarAppConnection.current
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
if (actions.isEmpty() && mapActions.isEmpty()) return
// A template that stops offering Pan must not leave the screen stuck in pan mode.
val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN }
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) CarServices.nav.setPanMode(false) }
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) connection.setPanMode(false) }
// Faded out when the screen has been left alone; then they are only a memory of buttons, so
// the first touch on one wakes them rather than pressing it.
val awake = LocalMapControlsWake.current.awake
val pan by CarServices.nav.panMode.collectAsState()
val pan by connection.panMode.collectAsState()
// Kept up while the knob is on one of them, or the ring would fade out along with the button.
var holdsFocus by remember { mutableStateOf(false) }
val visible = awake || pan || holdsFocus
val alpha by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (visible) 1f else 0f,
targetValue = if (visible) 1f else RESTING_ALPHA,
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
label = "mapControls",
)
@@ -299,13 +303,14 @@ private fun CarRow.isBlank(): Boolean =
*/
@Composable
internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modifier = Modifier) {
val connection = LocalCarAppConnection.current
if (action.type == Action.TYPE_APP_ICON) {
AppIconBadge()
return
}
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val title = action.title.text()
val panOn by CarServices.nav.panMode.collectAsState()
val panOn by connection.panMode.collectAsState()
val active = action.type == Action.TYPE_PAN && panOn
val scheme = MaterialTheme.colorScheme
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
@@ -320,8 +325,8 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
GsIconBox(
onClick = {
if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed()
else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn)
if (action.type == Action.TYPE_BACK) connection.backPressed()
else if (action.type == Action.TYPE_PAN) connection.setPanMode(!panOn)
else action.onClickDelegate?.click("action")
},
modifier = modifier,
@@ -432,8 +437,9 @@ private const val AUTO_START_MS = 5_000L
/** The connected app's own launcher icon, which is what the app-icon action stands for. */
@Composable
private fun AppIconBadge() {
val connection = LocalCarAppConnection.current
val context = LocalContext.current
val pkg = CarServices.nav.appPackage
val pkg = connection.appPackage
val icon = remember(pkg) {
pkg?.let {
runCatching {