Merge pull request #4230 from vitorpamplona/claude/confident-goodall-ekfh2s

Add navDrawer() to distinguish drawer destinations from in-app pushes
This commit is contained in:
Vitor Pamplona
2026-09-27 14:36:32 -04:00
committed by GitHub
21 changed files with 279 additions and 42 deletions
@@ -136,6 +136,7 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip56Reports.ReportType
@@ -221,6 +222,11 @@ fun BlossomBlobManagerScreen(
},
)
},
bottomBar = {
AppBottomBar(Route.ManageBlossomBlobs, nav, accountViewModel) { route ->
if (route != Route.ManageBlossomBlobs) nav.navBottomBar(route)
}
},
) { padding ->
Column(
modifier =
@@ -54,6 +54,14 @@ const val BOTTOM_NAV_ROOT_KEY = "bottomNavRoot"
fun NavBackStackEntry.isBottomNavRoot(): Boolean = savedStateHandle.get<Boolean>(BOTTOM_NAV_ROOT_KEY) == true
// Per-entry hint stamped by Nav.navDrawer marking that the entry was opened
// from the navigation drawer. It sits on top of the stack like any push (back
// arrow, slide animation), but Nav.showsBottomBar keeps the bottom bar on it:
// a drawer destination is a top-level section, not a detail screen.
const val DRAWER_ROOT_KEY = "drawerRoot"
fun NavBackStackEntry.isDrawerRoot(): Boolean = savedStateHandle.get<Boolean>(DRAWER_ROOT_KEY) == true
/**
* The shell's current layout tier, mirrored for the transition specs below. Transition
* lambdas run when a navigation starts — outside composition — so they can't read
@@ -68,7 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/** Content height of the [AppBottomBar] (the 50.dp Column inside [RenderBottomMenu]),
* exclusive of the system navigation-bar inset. Used by FAB callers that want to
* reserve the same vertical space when the bar hides itself on canPop entries. */
* reserve the same vertical space when the bar hides itself on in-app pushes. */
val AppBottomBarHeight = 50.dp
@Composable
@@ -94,9 +94,9 @@ fun AppBottomBar(
// permanently docked drawer (Expanded).
if (LocalScreenLayout.current.isLargeScreen) return
// Hide the bar on entries that aren't a tab root (drawer or in-app
// pushes). Mirrors the back-arrow rule in canPop().
if (nav.canPop()) return
// Hide the bar on in-app pushes. Tab roots, Home and screens opened from
// the drawer keep it, even though the drawer ones still show a back arrow.
if (!nav.showsBottomBar()) return
val items by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems
.collectAsStateWithLifecycle()
@@ -210,7 +210,7 @@ private fun DrawerContentBody(
accountViewModel: AccountViewModel,
) {
val onClickUser = {
nav.nav(routeFor(accountViewModel.userProfile()))
nav.navDrawer(routeFor(accountViewModel.userProfile()))
nav.closeDrawer()
}
@@ -641,24 +641,32 @@ fun ListContent(
}
}
/** The Create section's rows — composer entry points, none of which is a catalog destination. */
/**
* The Create section's rows — composer entry points, none of which is a catalog destination. They
* open as plain pushes rather than through [INav.navDrawer]: those screens host no bottom bar, and
* a drawer stamp would tell FabBottomBarPadding that one is showing.
*/
@Composable
private fun CreateRows(nav: INav) {
NavigationRow(
IconRow(
title = Res.string.share_hls_video,
icon = MaterialSymbols.SettingsInputAntenna,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.NewHlsVideo,
onClick = {
nav.closeDrawer()
nav.nav(Route.NewHlsVideo)
},
)
if (isDebug) {
NavigationRow(
IconRow(
title = Res.string.route_chess,
icon = MaterialSymbols.ChessKnight,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Chess,
onClick = {
nav.closeDrawer()
nav.nav(Route.Chess)
},
)
}
}
@@ -742,7 +750,7 @@ private fun ScheduledPostsNavigationRow(
badgeCount = pendingCount,
onClick = {
nav.closeDrawer()
nav.nav { def.resolveRoute(accountViewModel) }
nav.navDrawer { def.resolveRoute(accountViewModel) }
},
)
}
@@ -850,7 +858,7 @@ fun NavigationRow(
tint,
onClick = {
nav.closeDrawer()
nav.nav(route)
nav.navDrawer(route)
},
)
}
@@ -871,7 +879,7 @@ fun NavigationRow(
tint,
onClick = {
nav.closeDrawer()
nav.nav(computeRoute)
nav.navDrawer(computeRoute)
},
)
}
@@ -890,7 +898,7 @@ fun NavigationRow(
tint = tint,
onClick = {
nav.closeDrawer()
nav.nav(route)
nav.navDrawer(route)
},
)
}
@@ -909,7 +917,7 @@ fun NavigationRow(
tint = tint,
onClick = {
nav.closeDrawer()
nav.nav(computeRoute)
nav.navDrawer(computeRoute)
},
)
}
@@ -36,7 +36,9 @@ import androidx.navigation.NavHostController
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.BOTTOM_NAV_ROOT_KEY
import com.vitorpamplona.amethyst.ui.navigation.DRAWER_ROOT_KEY
import com.vitorpamplona.amethyst.ui.navigation.isBottomNavRoot
import com.vitorpamplona.amethyst.ui.navigation.isDrawerRoot
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -89,6 +91,31 @@ class Nav(
}
}
override fun navDrawer(route: Route) {
navigationScope.launch {
ime.settle()
navigateFromDrawer(route)
}
}
override fun navDrawer(computeRoute: suspend () -> Route?) {
navigationScope.launch {
ime.settle()
computeRoute()?.let { navigateFromDrawer(it) }
}
}
/**
* Same push as [nav], then stamps the new entry [DRAWER_ROOT_KEY] so [showsBottomBar] keeps
* the bottom bar on it. The stamp lands in the same frame as the navigate, before the entry
* composes, the way [navBottomBar] stamps its tab roots.
*/
private fun navigateFromDrawer(route: Route) {
if (getRouteWithArguments(route::class, controller) == route) return
controller.navigate(route)
controller.currentBackStackEntry?.savedStateHandle?.set(DRAWER_ROOT_KEY, true)
}
override fun newStack(route: Route) {
navigationScope.launch {
ime.settle()
@@ -107,9 +134,10 @@ class Nav(
// A nav-bar tap asks for a tab, never for whatever the user pushed on top of one. Drop
// those pushes first, and without saving them, so the restoreState below can never hand
// a deep stack back. On phones this is always a no-op — AppBottomBar hides itself off
// tab roots, so the bar is only ever tapped from one — but the large-screen rail stays
// on screen the whole time and is routinely tapped from three screens deep.
// a deep stack back. On phones the bar shows only on tab roots and on screens opened
// from the drawer (dropped here, back to the tab they were opened over), but the
// large-screen rail stays on screen the whole time and is routinely tapped from three
// screens deep.
popPushesAboveTabRoot()
// Dropping those pushes is often the whole job — re-tapping the tab the user is inside,
@@ -211,11 +239,24 @@ class Nav(
// Outside a NavHost destination (shell chrome, drawer) the current owner
// is the account-scoped ViewModelStoreOwner, not an entry; fall back to
// the globally-current entry so those callers keep their prior behavior.
val entry =
(LocalViewModelStoreOwner.current as? NavBackStackEntry)
?: controller.currentBackStackEntry
?: return false
val entry = ownEntry() ?: return false
return canPop(entry)
}
@Composable
override fun showsBottomBar(): Boolean {
val entry = ownEntry() ?: return true
// Drawer destinations can pop (they sit on whatever screen the drawer was opened over),
// but they are top-level sections, so they keep the bar the tab roots have.
return entry.isDrawerRoot() || !canPop(entry)
}
@Composable
private fun ownEntry(): NavBackStackEntry? =
(LocalViewModelStoreOwner.current as? NavBackStackEntry)
?: controller.currentBackStackEntry
private fun canPop(entry: NavBackStackEntry): Boolean {
// Hidden on tab roots (reached via the bottom nav) and on Home (the
// graph's start destination): nothing sits below either that a back
// arrow could return to. Every other entry is a push on top of Home,
@@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.model.cordn.CordnRuntime
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.CordnGroupRoomCompose
@@ -125,6 +126,11 @@ fun CordnGroupListScreen(
}
}
},
bottomBar = {
AppBottomBar(Route.CordnGroupList, nav, accountViewModel) { route ->
if (route != Route.CordnGroupList) nav.navBottomBar(route)
}
},
) { padding ->
if (runtime == null) {
EmptyState(
@@ -91,6 +91,7 @@ import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2
@@ -136,16 +137,24 @@ fun MarmotGroupListScreen(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(Res.string.back),
)
// No arrow as a bottom-nav tab root: there is nothing below it to return to.
if (nav.canPop()) {
IconButton(onClick = { nav.popBack() }) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(Res.string.back),
)
}
}
},
title = { Text(stringRes(Res.string.marmot_groups_title)) },
)
},
bottomBar = {
AppBottomBar(Route.MarmotGroupList, nav, accountViewModel) { route ->
if (route != Route.MarmotGroupList) nav.navBottomBar(route)
}
},
floatingActionButton = {
FabBottomBarPadded(nav) {
FloatingActionButton(onClick = { nav.nav(Route.CreateMarmotGroup) }, shape = CircleShape) {
@@ -353,7 +353,7 @@ fun ConcordChannelListScreen(
)
},
bottomBar = {
// Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop),
// Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()),
// so a pinned Concord community works both as a pushed detail and as a bottom-nav tab.
AppBottomBar(Route.ConcordServer(communityId), nav, accountViewModel) { route ->
if (route != Route.ConcordServer(communityId)) nav.navBottomBar(route)
@@ -137,7 +137,7 @@ fun ConcordHomeScreen(
)
},
bottomBar = {
// Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop),
// Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()),
// so the same screen works both as a pushed destination and a bottom-nav tab.
AppBottomBar(Route.Concords, nav, accountViewModel) { route ->
if (route != Route.Concords) nav.navBottomBar(route)
@@ -56,7 +56,7 @@ fun PublicChatChannelScreen(
PublicChatTopBar(it, accountViewModel, nav)
}
},
// Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop),
// Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()),
// so a pinned public chat works both as a pushed detail and as a bottom-nav tab.
bottomBar = {
AppBottomBar(selfRoute, nav, accountViewModel) { route ->
@@ -439,7 +439,7 @@ fun RelayGroupChannelListScreen(
)
},
bottomBar = {
// Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop),
// Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()),
// so a pinned NIP-29 relay works both as a pushed detail and as a bottom-nav tab.
AppBottomBar(selfRoute, nav, accountViewModel) { route ->
if (route != selfRoute) nav.navBottomBar(route)
@@ -58,7 +58,7 @@ fun RelayGroupChatScreen(
RelayGroupTopBar(it, inviteCode, accountViewModel, nav)
}
},
// Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop),
// Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()),
// so a pinned relay group works both as a pushed detail and as a bottom-nav tab.
bottomBar = {
AppBottomBar(selfRoute, nav, accountViewModel) { route ->
@@ -107,8 +107,12 @@ fun GeocachesScreen(
}
},
bottomBar = {
AppBottomBar(Route.Geocaches(), nav, accountViewModel) { route ->
if (route is Route.Geocaches) {
// Geocaches and Hunts are two separate pinnable tabs of this one screen, so match the
// exact route: a class check highlighted the wrong one and turned a tap on the other
// into a scroll-to-top instead of a tab switch.
val selfRoute = Route.Geocaches(initialTab)
AppBottomBar(selfRoute, nav, accountViewModel) { route ->
if (route == selfRoute) {
nearby.sendToTop()
} else {
nav.navBottomBar(route)
@@ -37,11 +37,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_none_found
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssemblerSubscription
@@ -90,6 +92,11 @@ fun NappletsScreen(
Scaffold(
topBar = { NappletsTopBar(accountViewModel, nav) },
bottomBar = {
AppBottomBar(Route.Napplets, nav, accountViewModel) { route ->
if (route != Route.Napplets) nav.navBottomBar(route)
}
},
) { padding ->
if (visible.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
@@ -37,10 +37,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.nsite_none_found
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssemblerSubscription
@@ -88,6 +90,11 @@ fun NsitesScreen(
Scaffold(
topBar = { NsitesTopBar(accountViewModel, nav) },
bottomBar = {
AppBottomBar(Route.Nsites, nav, accountViewModel) { route ->
if (route != Route.Nsites) nav.navBottomBar(route)
}
},
) { padding ->
if (visible.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
@@ -125,6 +125,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackBu
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
@@ -193,6 +194,11 @@ fun Nip46SignerScreen(
Scaffold(
topBar = { TopBarWithBackButton(stringRes(Res.string.nip46_signer_title), nav) },
bottomBar = {
AppBottomBar(Route.Nip46Signer(), nav, accountViewModel) { route ->
if (route != Route.Nip46Signer()) nav.navBottomBar(route)
}
},
) { padding ->
Column(
modifier =
@@ -42,8 +42,8 @@ import org.junit.Test
/**
* A nav-bar tap must land on the tab itself, never on whatever the user had pushed on top of one.
*
* The phone bottom bar gets this for free — it hides itself off tab roots, so it can only ever be
* tapped from one. The large-screen navigation rail stays on screen the whole time, which is where
* The phone bottom bar shows only on tab roots and drawer destinations, so it is tapped from at most
* one screen above a tab. The large-screen navigation rail stays on screen the whole time, which is where
* the gap showed: tapping Home from a thread three screens deep came back to that thread instead of
* the feed.
*
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
* A screen opened from the navigation drawer is a top-level section: it keeps the bottom bar
* (issue #4141, where Pictures and every other drawer destination lost it). It still sits on top of
* the screen the drawer was opened over, so it keeps its back arrow too; the bar is told apart
* from an in-app push by the [DRAWER_ROOT_KEY] stamp these tests pin down.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NavDrawerTest {
private fun entry(
isTabRoot: Boolean = false,
isDrawerRoot: Boolean = false,
): NavBackStackEntry =
mockk<NavBackStackEntry>(relaxed = true) {
every { savedStateHandle.get<Boolean>(BOTTOM_NAV_ROOT_KEY) } returns isTabRoot
every { savedStateHandle.get<Boolean>(DRAWER_ROOT_KEY) } returns isDrawerRoot
}
/** A controller whose current entry becomes [pushed] once [route] is navigated to. */
private fun controllerPushing(
route: Route,
pushed: NavBackStackEntry,
): NavHostController {
var current = entry(isTabRoot = true)
return mockk<NavHostController>(relaxed = true) {
every { currentBackStackEntry } answers { current }
every { navigate(route) } answers { current = pushed }
}
}
@Test
fun stampsTheEntryItOpens() =
runTest {
val pushed = entry()
val controller = controllerPushing(Route.Pictures(), pushed)
Nav(controller, this).navDrawer(Route.Pictures())
advanceUntilIdle()
verify(exactly = 1) { controller.navigate(Route.Pictures()) }
verify(exactly = 1) { pushed.savedStateHandle[DRAWER_ROOT_KEY] = true }
}
@Test
fun stampsTheEntryOfAResolvedRoute() =
runTest {
val pushed = entry()
val controller = controllerPushing(Route.Articles, pushed)
Nav(controller, this).navDrawer { Route.Articles }
advanceUntilIdle()
verify(exactly = 1) { pushed.savedStateHandle[DRAWER_ROOT_KEY] = true }
}
@Test
fun plainPushesAreNotStamped() =
runTest {
val pushed = entry()
val controller = controllerPushing(Route.Pictures(), pushed)
Nav(controller, this).nav(Route.Pictures())
advanceUntilIdle()
verify(exactly = 0) { pushed.savedStateHandle[DRAWER_ROOT_KEY] = any<Boolean>() }
}
@Test
fun aTabTappedFromADrawerScreenDropsItFirst() =
runTest {
// Home > Pictures (from the drawer): the bar now shows on Pictures, so it can be tapped
// from there. The drawer entry is not a tab root and must not be saved into the tab.
val controller =
mockk<NavHostController>(relaxed = true) {
every { currentBackStackEntry } returnsMany
listOf(entry(isDrawerRoot = true), entry(isTabRoot = true))
every { popBackStack() } returns true
}
Nav(controller, this).navBottomBar(Route.Message)
advanceUntilIdle()
verify(exactly = 1) { controller.popBackStack() }
}
}
@@ -199,7 +199,7 @@ private fun ScaffoldLayout(
}.firstOrNull()?.measure(looseConstraints)
}
// When the bar lambda is provided but its content emits nothing (e.g. AppBottomBar
// hides itself on canPop entries, or while the keyboard is up), reserve the
// hides itself on in-app pushes, or while the keyboard is up), reserve the
// system-nav-bar inset so the FAB and content stay clear of the navigation bar instead
// of sliding under it. Subtract the IME inset: the root imePadding has already lifted the
// whole scaffold above the keyboard, and the IME inset spans the nav-bar band, so
@@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
/**
* Reserves the visual space the `AppBottomBar` occupies on root tab entries so a
* FloatingActionButton stays at the same vertical position whether or not the bar is
* rendered. `AppBottomBar` hides itself on canPop entries (drawer pushes, in-app
* rendered. `AppBottomBar` hides itself off [INav.showsBottomBar] entries (in-app
* navigations) — without this padding the FAB drops by `AppBottomBarHeight` there.
*
* The system-navigation-bar inset is already handled by the surrounding Scaffold, so
@@ -41,8 +41,8 @@ val FABPaddingFromBottom = 30.dp
@Composable
fun Modifier.fabBottomBarPadding(nav: INav): Modifier =
if (nav.canPop() || LocalScreenLayout.current.isLargeScreen) {
// canPop entries hide the bar on phones; large screens never render it at all.
if (!nav.showsBottomBar() || LocalScreenLayout.current.isLargeScreen) {
// In-app pushes hide the bar on phones; large screens never render it at all.
padding(bottom = FABPaddingFromBottom)
} else {
this
@@ -52,9 +52,26 @@ interface INav {
fun navBottomBar(route: Route)
/**
* Opens [route] as a top-level destination picked from the navigation drawer. It still
* stacks on top of the current screen (so it [canPop]), but unlike an in-app push it keeps
* the bottom bar — see [showsBottomBar].
*/
fun navDrawer(route: Route) = nav(route)
/** [navDrawer], for a route that has to be resolved first. */
fun navDrawer(computeRoute: suspend () -> Route?) = nav(computeRoute)
@Composable
fun canPop(): Boolean
/**
* Whether this screen renders the bottom bar: tab roots, the start destination and
* destinations opened from the drawer ([navDrawer]) do; in-app pushes don't.
*/
@Composable
fun showsBottomBar(): Boolean = !canPop()
fun popBack()
fun <T : Route> popUpTo(