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 {