From d90c39f50a1893903cb79c7520905f6da62128ac Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Sat, 24 Oct 2026 14:24:28 -0700 Subject: [PATCH] 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. --- .../seb3thehacker/gearslip/car/CarAppStage.kt | 46 +++++++++++-------- .../gearslip/car/TemplateChrome.kt | 33 +++++++------ .../gearslip/car/TemplateContent.kt | 3 +- .../gearslip/car/TemplateParts.kt | 20 +++++--- 4 files changed, 62 insertions(+), 40 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt index 85ffcfc..ecfb7ab 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt @@ -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 } 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 70881c6..4e67d79 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt @@ -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() = diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt index 4b29ac9..ea52ddf 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt @@ -517,7 +517,8 @@ private val sectionCache = android.util.LruCache>(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 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt index 0256025..c9d31be 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -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 {