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 ce16105..8b71012 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt @@ -62,6 +62,7 @@ fun CarAppStage( val template by connection.template.collectAsStateWithLifecycle() val cover = remember { ChromeCover() } + val navBar = LocalNavBarInset.current Box(Modifier.fillMaxSize()) { // The app's own drawing goes underneath; everything else is the host's rendering of @@ -70,7 +71,11 @@ fun CarAppStage( 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, bottom = navBar), + ) + BottomCover(navBar, Modifier.align(Alignment.BottomCenter)) } if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter)) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt index 7ab791c..c157ebe 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt @@ -104,7 +104,7 @@ fun CarHome() { MapPane(navStatus, frame) } if (splitScreen) { - val side = Modifier.weight(1f).fillMaxHeight() + val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current) if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt index 1be58f2..f0192a9 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -18,6 +18,9 @@ enum class Units { AUTO, IMPERIAL, METRIC } /** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */ enum class AppTheme { PHONE, LIGHT, DARK } +/** When the nav bar gets out of the way: never, during a route, or whenever the map is up. */ +enum class NavBarMode { SHOW, HIDE_NAVIGATING, HIDE } + /** * Settings changed from the car screen itself. Held as flows so the car UI reacts the moment a * button is tapped, and persisted through [AppSettings] so they survive the next connection. @@ -45,6 +48,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning" private const val KEY_CAR_SENSORS = "car_sensors_enabled" private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning" private const val KEY_UNITS = "car_units" + private const val KEY_NAV_BAR = "car_nav_bar" private const val KEY_WHATS_NEW = "car_whats_new_after_updates" private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code" @@ -84,6 +88,14 @@ private const val KEY_LOCATION_WARNING = "car_location_warning" val units: StateFlow = unitsFlow + private val navBarFlow = MutableStateFlow(NavBarMode.SHOW) + val navBarMode: StateFlow = navBarFlow + + fun setNavBarMode(mode: NavBarMode) { + navBarFlow.value = mode + AppSettings.putString(app, KEY_NAV_BAR, mode.name) + } + private val whatsNewFlow = MutableStateFlow(true) private val whatsNewSeenFlow = MutableStateFlow(0) @@ -243,6 +255,9 @@ private const val KEY_LOCATION_WARNING = "car_location_warning" unitsFlow.value = runCatching { Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name)) }.getOrDefault(Units.AUTO) + navBarFlow.value = runCatching { + NavBarMode.valueOf(AppSettings.getString(app, KEY_NAV_BAR, NavBarMode.SHOW.name)) + }.getOrDefault(NavBarMode.SHOW) whatsNewFlow.value = AppSettings.getString(app, KEY_WHATS_NEW, "true") == "true" whatsNewSeenFlow.value = AppSettings.getString(app, KEY_WHATS_NEW_SEEN, "0").toIntOrNull() ?: 0 } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt index c6248d4..d65412b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -51,6 +51,7 @@ fun CarSettingsScreen() { val open by CarSettings.openOnConnect.collectAsState() val night by CarSettings.nightMode.collectAsState() val units by CarSettings.units.collectAsState() + val navBar by CarSettings.navBarMode.collectAsState() val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState() val pipeAudio by CarSettings.pipeAudio.collectAsState() val speakDirections by CarSettings.speakDirections.collectAsState() @@ -115,6 +116,16 @@ fun CarSettingsScreen() { units, ) { CarSettings.setUnits(it) } + ChoiceRow( + "Nav bar", + listOf( + NavBarMode.SHOW to "Always show", + NavBarMode.HIDE_NAVIGATING to "Hide while navigating", + NavBarMode.HIDE to "Hide until tapped", + ), + navBar, + ) { CarSettings.setNavBarMode(it) } + ChoiceRow( "Player in the nav bar", listOf(false to "Skip buttons", true to "Song title"), diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index 8ca4826..8f9638b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -211,6 +211,22 @@ fun CarUi() { } val screen = navigator.current val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState() + val keyboardVisible by CarKeyboardVisibility.state + // Off "Always show", the bar floats over the map rather than taking a row of its + // own: hiding it then leaves the map's surface the size it was. Resizing it would + // hand the map app a new surface each time, and some go black on that. + val navBarMode by CarSettings.navBarMode.collectAsState() + val appTemplate by CarServices.nav.template.collectAsState() + val floatingBar = navBarMode != NavBarMode.SHOW + val barMayHide = screen == CarScreen.Home && when (navBarMode) { + NavBarMode.SHOW -> false + NavBarMode.HIDE_NAVIGATING -> appTemplate is androidx.car.app.navigation.model.NavigationTemplate + NavBarMode.HIDE -> true + } + // A full wait from when hiding becomes possible, not from whenever the last touch was. + LaunchedEffect(barMayHide) { if (barMayHide) controlsWake.touch() } + val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake) + val homeInset = if (floatingBar && barShown) NAV_BAR_HEIGHT else 0.dp Box(Modifier.fillMaxSize().padding(pad)) { Column(Modifier.fillMaxSize()) { if (!safetyWarningSeen) { @@ -223,12 +239,14 @@ fun CarUi() { // Home never leaves the composition; other screens cover it. Dropping it // would tear down the map's surface, and coming back would then show black // while the nav app redrew on a new one. - CarHome() + CompositionLocalProvider(LocalNavBarInset provides homeInset) { CarHome() } if (screen != CarScreen.Home) { Box( Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) + // The floating bar never hides here, so keep the screen clear of it. + .padding(bottom = if (floatingBar && !keyboardVisible) NAV_BAR_HEIGHT else 0.dp) // Keeps touches that miss this screen's controls off the map. .pointerInput(Unit) { detectTapGestures { } }, ) { @@ -247,7 +265,7 @@ fun CarUi() { } } } - CarToastOverlay(Modifier.align(Alignment.BottomCenter)) + CarToastOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset)) CallPromptOverlay() BluetoothPairOverlay() PopupTimer() @@ -259,20 +277,27 @@ fun CarUi() { // Above the popup: a call is more urgent than any notification. VoiceHelpOverlay(Modifier.align(Alignment.Center)) CallOverlay(Modifier.align(Alignment.TopCenter)) - AssistantOverlay(Modifier.align(Alignment.BottomCenter)) + AssistantOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset)) if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) { // Bottom corner, not top: a screen's own header almost always puts // something in the top-right (settings, search, a Liked Songs // shortcut) and this badge used to sit right on top of it, hiding // whatever the screen drew there. - DebugBuildBadge(Modifier.align(Alignment.BottomEnd)) + DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset)) + } + if (floatingBar) { + androidx.compose.animation.AnimatedVisibility( + barShown, + Modifier.align(Alignment.BottomCenter), + enter = androidx.compose.animation.slideInVertically { it }, + exit = androidx.compose.animation.slideOutVertically { it }, + ) { CarNavBar(navigator) } } } // The keyboard takes the nav bar's row instead of squeezing in above it - // nothing on the nav bar is useful mid-type, and the screen is too small to // show both. - val keyboardVisible by CarKeyboardVisibility.state - if (!keyboardVisible) CarNavBar(navigator) + if (!floatingBar && !keyboardVisible) CarNavBar(navigator) } // Over everything, nav bar included: a long press on an app, anywhere. AppMenu(navigator, rememberRunningApps()) @@ -284,6 +309,14 @@ fun CarUi() { } } +private val NAV_BAR_HEIGHT = 56.dp + +/** + * How much of the bottom of Home the floating nav bar covers right now: its height while it shows, + * zero while it's hidden or when it has a row of its own. + */ +internal val LocalNavBarInset = androidx.compose.runtime.compositionLocalOf { 0.dp } + /** * A single blocking screen, shown once per install before anything else the car screen can show - * Home included. The phone's own setup guide carries the same warning, but a driver who plugs in @@ -462,7 +495,7 @@ private fun CarNavBar(navigator: CarNavigator) { val running = rememberRunningApps() Surface(color = MaterialTheme.colorScheme.surfaceContainer) { - BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) { + BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) { Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { // Home and Apps are two different places now, not a toggle on one button - the // breadcrumb trail is how you retrace your steps, but reaching for either of diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MapControlsWake.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MapControlsWake.kt index 7abada8..eacb9ed 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MapControlsWake.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MapControlsWake.kt @@ -11,27 +11,35 @@ import android.os.SystemClock * * They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear, * and any touch brings them back. [touch] is called for every pointer event and is cheap: it only - * writes state when the buttons were actually hidden. + * writes state when the buttons were actually hidden. The nav bar, when set to hide, goes the + * same way but waits longer ([NAV_BAR_IDLE_MS]). */ class MapControlsWake { var awake by mutableStateOf(true) private set + var navBarAwake by mutableStateOf(true) + private set + private var lastTouch = SystemClock.uptimeMillis() fun touch() { lastTouch = SystemClock.uptimeMillis() if (!awake) awake = true + if (!navBarAwake) navBarAwake = true } /** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */ fun tick() { - if (awake && SystemClock.uptimeMillis() - lastTouch >= IDLE_MS) awake = false + val idle = SystemClock.uptimeMillis() - lastTouch + if (awake && idle >= IDLE_MS) awake = false + if (navBarAwake && idle >= NAV_BAR_IDLE_MS) navBarAwake = false } companion object { const val IDLE_MS = 4_000L + const val NAV_BAR_IDLE_MS = 8_000L } } 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 2627e34..691eac3 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, height) + -> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, measured?.bottom ?: height) null -> Rect(0, 0, width, height) else -> Rect(0, 0, 0, 0) } @@ -150,9 +150,18 @@ internal class ChromeCover { internal val LocalChromeCover = compositionLocalOf { null } +/** + * The floating nav bar, as far as the app's map is concerned. Its full [height] at once rather + * than following the slide, so the app is told once instead of every frame. + */ +@Composable +internal fun BottomCover(height: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) { + Box(modifier.fillMaxWidth().height(height).covers("nav-bar", CoverEdge.BOTTOM)) +} + /** Records where this lands, padding included, until it leaves the screen. */ @Composable -private fun Modifier.covers(key: String, edge: CoverEdge): Modifier { +internal 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()) } @@ -171,7 +180,9 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: 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. + * map keeps its full width. Neither grows past [NAV_BAR_MAX_WIDTH]. The bottom one is short and + * left out of the visible area on purpose, so a speedometer in the app's bottom corner sits level + * with it rather than stacked above it. The map buttons sit between them on the right. */ @Composable private fun NavigationBars( @@ -194,7 +205,7 @@ private fun NavigationBars( estimate?.let { TravelEstimateBar( it, - Modifier.align(Alignment.BottomStart).covers("nav-bottom", CoverEdge.BOTTOM).padding(12.dp) + Modifier.align(Alignment.BottomStart).padding(12.dp) .widthIn(max = NAV_BAR_MAX_WIDTH), ) }