feat: give the app a navigation component, and stop the app bar duplicating it
Phase 6, step 3. The app had no navigation component of any kind: 43 screens reached by pushing a route, and one home screen whose top app bar carried the only two peer surfaces -- a profile avatar in the leading slot, a search icon in the trailing one. **This is an information-architecture change and was taken as one.** With a single top-level destination, a navigation bar would have held one item and been strictly worse than the app bar it replaced -- M3's caution is to swap only functionally equivalent components. Promoting search and profile to peer destinations is what makes a navigation component mean anything here, and it was put to the product owner rather than inferred. Answered: promote them. The consequence is in `HomeScreen`: the app bar now carries a title and nothing else. Two routes to one destination is the thing the caution is about, and the navigation component is now the one route, at every breakpoint. **Which component, at which breakpoint**, straight from the layout foundation: | compact | navigation bar | | medium, expanded | collapsed rail | | large, extra-large | expanded rail | `NavigationSuiteScaffoldDefaults.navigationSuiteType` is not used, and the difference is the last row -- it stops at the collapsed rail, because it classifies with the three-value window size class rather than the five breakpoints the May 2026 revision published. Deriving from `Breakpoint` reaches the row the library's default cannot, and keeps one source of truth for window width in the app. `NavigationSuiteType.None` on everything else. A navigation bar belongs on the destinations it switches between; on a chat room, a signing screen or an onboarding step -- pushed to and left by coming back -- it is a permanent invitation to lose your place. **Two things the wiring needed.** `ActiveProfileRoute` is addressed by metadata event id, not by public key, and only the home screen ever had one. The nav host now observes it for as long as a key is signed in, and the profile item is *disabled* until it arrives rather than absent -- an item that appears late moves the two beside it, and a bar whose items move under a thumb is worse than one briefly unavailable. The item click pops to `HomeRoute`, not to the graph's start destination. The android docs give the second shape and it would be wrong here: this graph starts at `LoadingRoute`, and onboarding clears the stack with `popUpTo(0)` on its way to home, so by the time these items exist the start destination is not on the stack at all -- popping to it would leave the loading screen underneath as the thing back returns to. **Tests, and one that could not be written.** The breakpoint-to-component table is a pure function so all five rows are asserted; the two rail rows differ only in whether labels are drawn, and nobody opens a 1200dp window on purpose. Four more compose the component around a real nav graph, because `TopLevelDestination.of` matches by `hasRoute` -- reflection over the serialized route -- and a renamed route would fail by never showing the component at all. Navigation in those is driven through the controller rather than by tapping an item. That is a harness limitation, established rather than assumed: a click handler that navigates trips navigation-compose's own main-thread assertion under `runDesktopComposeUiTest`, reproducible in twenty lines containing no app code -- a `NavHost`, two routes and a `TextButton`. What an item's `onClick` builds is asserted where it is a pure function instead. Also `material3-adaptive-navigation-suite`, versioned with material3 rather than with the adaptive library: it is published by the material3 group, and its 1.10.0-alpha05 is what names adaptive 1.2.0 in the first place. Most of the `MantraNavHost` diff is indentation -- the `NavHost` call gained an enclosing composable. `git diff -w` shows the 38 lines that are not. 626 jvm tests green; android compiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,7 @@ kotlin {
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.material3.adaptive)
|
||||
implementation(libs.compose.material3.adaptive.navigation.suite)
|
||||
implementation (libs.compose.material.icons.core)
|
||||
implementation (libs.compose.material.icons.extended)
|
||||
implementation(libs.compose.ui)
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
<string name="post">Post</string>
|
||||
<string name="post_functionality_coming_soon">post functionality coming soon.</string>
|
||||
<string name="private_to_you">Private to you</string>
|
||||
<string name="messages">Messages</string>
|
||||
<string name="profile">Profile</string>
|
||||
<string name="profile_is_ready">Profile is ready</string>
|
||||
<string name="profile_keys">Profile keys</string>
|
||||
|
||||
@@ -2,19 +2,16 @@ package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -22,16 +19,14 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -42,12 +37,10 @@ import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.intermdiate.LocalProfileWithFollowing
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ActiveProfileRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
import press.mantra.compose.ui.composable.widgets.dialogs.NewChatBottomSheetDialog
|
||||
import press.mantra.compose.ui.composable.widgets.dialogs.StartDirectMessageToNpubOrNip05Dialog
|
||||
import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
|
||||
import press.mantra.compose.ui.theme.TorchTheme
|
||||
import press.mantra.compose.ui.view.model.ChatRoomListViewModel
|
||||
import press.mantra.compose.ui.view.model.HomeScreenType
|
||||
@@ -61,7 +54,6 @@ import mantra.composeapp.generated.resources.Res
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import mantra.composeapp.generated.resources.mantra
|
||||
import mantra.composeapp.generated.resources.new_chat
|
||||
import mantra.composeapp.generated.resources.something_went_wrong
|
||||
import press.mantra.compose.ui.composable.widgets.ErrorState
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
@@ -75,7 +67,6 @@ fun HomeScreen(
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
onNavigateToDirectMessageDetail: (Route) -> Unit,
|
||||
onNavigateToChatRoomCreation: () -> Unit,
|
||||
onNavigateToSearch: () -> Unit,
|
||||
nostrRepository: NostrRepository,
|
||||
chatRepository: ChatRepository
|
||||
) {
|
||||
@@ -124,35 +115,13 @@ fun HomeScreen(
|
||||
// a content one.
|
||||
Text(stringResource(Res.string.mantra))
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
ActiveProfileRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
nostrEventId = homeScreenUIState.profileWithFollowing.nostrEvent.id
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
ProfileAvatar(
|
||||
profile = homeScreenUIState.profileWithFollowing.profile,
|
||||
publicKey = homeScreenUIState.profileWithFollowing.profile.publicKey
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onNavigateToSearch.invoke()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = "Search"
|
||||
)
|
||||
}
|
||||
}
|
||||
// No navigation icon and no actions. The avatar in the leading
|
||||
// slot and the search icon in the trailing one were the app's only
|
||||
// two peer surfaces, and they are now items in the navigation
|
||||
// component -- a bar on a phone, a rail on a wide window. Leaving
|
||||
// them here as well would be two routes to one destination, which
|
||||
// is the thing M3's "swap only functionally equivalent components"
|
||||
// caution is about.
|
||||
)
|
||||
|
||||
},
|
||||
@@ -305,7 +274,6 @@ It has survived not only five centuries, but also the leap into electronic types
|
||||
),
|
||||
onNavigateToRoute = {},
|
||||
onNavigateToChatRoomCreation = {},
|
||||
onNavigateToSearch = {},
|
||||
onNavigateToDirectMessageDetail = {},
|
||||
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
package press.mantra.compose.ui.composable.navigation
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItem
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import press.mantra.compose.ui.composable.navigation.routes.HomeRoute
|
||||
import press.mantra.compose.ui.theme.Breakpoint
|
||||
|
||||
/**
|
||||
* The navigation bar, collapsed rail or expanded rail, around whatever the nav host draws.
|
||||
*
|
||||
* **Which component, at which breakpoint**, straight from the layout foundation's table:
|
||||
*
|
||||
* | breakpoint | component |
|
||||
* |---------------------|----------------------|
|
||||
* | compact | navigation bar |
|
||||
* | medium, expanded | collapsed rail |
|
||||
* | large, extra-large | expanded rail |
|
||||
*
|
||||
* `NavigationSuiteScaffoldDefaults.navigationSuiteType` is not used, and the difference is
|
||||
* the last row: it stops at `WideNavigationRailCollapsed` and never returns the expanded
|
||||
* rail, because it classifies with the three-value window size class rather than the five
|
||||
* breakpoints the May 2026 revision published. Deriving the type from [Breakpoint] instead
|
||||
* keeps one source of truth for window width in the app and reaches the row the library's
|
||||
* default cannot.
|
||||
*
|
||||
* **Hidden on everything else.** [NavigationSuiteType.None] draws no component at all, and
|
||||
* that is the case for every screen that is not one of the three: onboarding, a chat room,
|
||||
* a signing screen, the desktop passphrase gate. A navigation bar belongs on the
|
||||
* destinations it switches between; on a screen reached by pushing a route and left by
|
||||
* coming back it is a permanent invitation to lose your place.
|
||||
*
|
||||
* @param navController the host's controller, read for the current destination and used to
|
||||
* navigate. Passed rather than hoisted into callbacks because the top-level navigation
|
||||
* options below -- `saveState`, `restoreState`, `launchSingleTop` -- are the point of the
|
||||
* component and belong with it rather than at three call sites.
|
||||
* @param breakpoint the window's breakpoint, which decides the component.
|
||||
* @param activeUserPublicKey the signed-in key, or `null` before anybody has signed in --
|
||||
* in which case no destination has a route and the component stays hidden.
|
||||
* @param activeProfileNostrEventId the metadata event addressing the active profile, or
|
||||
* `null` until it has been read. Only [TopLevelDestination.Profile] needs it.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun MantraNavigationSuite(
|
||||
navController: NavHostController,
|
||||
breakpoint: Breakpoint,
|
||||
activeUserPublicKey: String?,
|
||||
activeProfileNostrEventId: String?,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val currentDestination: NavDestination? =
|
||||
navController.currentBackStackEntryAsState().value?.destination
|
||||
val selected = TopLevelDestination.of(currentDestination)
|
||||
|
||||
val suiteType = navigationSuiteTypeFor(
|
||||
breakpoint = breakpoint,
|
||||
// Not on a top-level destination, or nobody is signed in, so there is nothing for
|
||||
// the component to switch between.
|
||||
isTopLevel = selected != null && activeUserPublicKey != null,
|
||||
)
|
||||
|
||||
NavigationSuiteScaffold(
|
||||
navigationSuiteType = suiteType,
|
||||
navigationItems = {
|
||||
if (suiteType == NavigationSuiteType.None || activeUserPublicKey == null) return@NavigationSuiteScaffold
|
||||
|
||||
TopLevelDestination.entries.forEach { destination ->
|
||||
val route = destination.route(activeUserPublicKey, activeProfileNostrEventId)
|
||||
|
||||
NavigationSuiteItem(
|
||||
navigationSuiteType = suiteType,
|
||||
selected = destination == selected,
|
||||
// Disabled rather than absent while the profile's event id is still
|
||||
// being read. An item that appears and then disappears moves the two
|
||||
// beside it, and a bar whose items move under a thumb is worse than
|
||||
// one with a briefly unavailable item.
|
||||
enabled = route != null,
|
||||
onClick = {
|
||||
if (route != null && destination != selected) {
|
||||
navController.navigate(route) {
|
||||
// The standard top-level options, and each answers a
|
||||
// failure this app would otherwise have. Without
|
||||
// `popUpTo` the back stack grows by one every time
|
||||
// somebody taps between the three, so back becomes a
|
||||
// walk through their tapping history rather than a way
|
||||
// out. Without `launchSingleTop` a second tap on the
|
||||
// current item pushes a duplicate. `saveState` and
|
||||
// `restoreState` are what keep a scrolled feed scrolled
|
||||
// when you come back to it.
|
||||
//
|
||||
// Popped to `HomeRoute` and not to the graph's start
|
||||
// destination, which is the shape the android docs give
|
||||
// and which would be wrong here: this graph starts at
|
||||
// `LoadingRoute`, and onboarding clears the stack with
|
||||
// `popUpTo(0)` on its way to home -- so by the time these
|
||||
// items exist the start destination is not on the stack at
|
||||
// all, and popping to it would leave the loading screen
|
||||
// underneath as the thing back returns to. Home is the
|
||||
// root of the signed-in graph, and is what back should
|
||||
// reach from either of the other two.
|
||||
popUpTo<HomeRoute> { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
// Null, and decided rather than defaulted: the label is
|
||||
// beside the icon in the bar and in the expanded rail, and a
|
||||
// reader would hear the name twice. The collapsed rail is the
|
||||
// exception, and it draws the label as a tooltip that the
|
||||
// accessibility tree still carries.
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
label = { Text(stringResource(destination.label)) },
|
||||
)
|
||||
}
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The navigation component a window at [breakpoint] should carry, per the table above.
|
||||
*
|
||||
* Separate from the composable so the table can be asserted. A breakpoint-to-component
|
||||
* mapping is exactly the kind of thing that is wrong in one row and looks right in every
|
||||
* screenshot anybody takes: the two rail rows differ only in whether labels are shown, and
|
||||
* nobody opens a 1200dp window to check.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
internal fun navigationSuiteTypeFor(breakpoint: Breakpoint, isTopLevel: Boolean): NavigationSuiteType =
|
||||
when {
|
||||
!isTopLevel -> NavigationSuiteType.None
|
||||
breakpoint == Breakpoint.Compact -> NavigationSuiteType.ShortNavigationBarCompact
|
||||
breakpoint.isAtLeast(Breakpoint.Large) -> NavigationSuiteType.WideNavigationRailExpanded
|
||||
else -> NavigationSuiteType.WideNavigationRailCollapsed
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package press.mantra.compose.ui.composable.navigation
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Forum
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import mantra.composeapp.generated.resources.Res
|
||||
import mantra.composeapp.generated.resources.messages
|
||||
import mantra.composeapp.generated.resources.profile
|
||||
import mantra.composeapp.generated.resources.search
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ActiveProfileRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.HomeRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.navigation.routes.SearchRoute
|
||||
|
||||
/**
|
||||
* The three destinations the navigation component switches between.
|
||||
*
|
||||
* The app has one screen that is genuinely a top-level destination -- [Messages], which
|
||||
* is the home feed -- and two surfaces that were one tap from it through the home screen's
|
||||
* app bar: the profile avatar in the leading slot and the search icon in the trailing one.
|
||||
* Promoting those two is an information-architecture decision rather than a component
|
||||
* swap, and it was taken deliberately: M3's caution is to swap only functionally
|
||||
* equivalent components, and a navigation bar holding a single item would have been
|
||||
* strictly worse than the app bar it replaced.
|
||||
*
|
||||
* The consequence is that the home screen's app bar no longer carries either icon. Two
|
||||
* routes to the same destination is what the caution is about; the navigation component is
|
||||
* now the one route, at every breakpoint.
|
||||
*
|
||||
* @property icon the item's icon, and the only thing shown when the rail is collapsed.
|
||||
* @property label the item's text, shown in the bar and in the expanded rail.
|
||||
*/
|
||||
enum class TopLevelDestination(val icon: ImageVector, val label: StringResource) {
|
||||
Messages(Icons.Default.Forum, Res.string.messages),
|
||||
Search(Icons.Default.Search, Res.string.search),
|
||||
Profile(Icons.Default.Person, Res.string.profile);
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Which destination [destination] belongs to, or `null` for everything else.
|
||||
*
|
||||
* `null` is what hides the navigation component. A navigation bar belongs on the
|
||||
* screens it switches between and nowhere else -- it has no meaning during
|
||||
* onboarding, in a chat room, or on a signing screen, all of which are reached by
|
||||
* pushing a route and left by coming back.
|
||||
*/
|
||||
fun of(destination: NavDestination?): TopLevelDestination? = when {
|
||||
destination == null -> null
|
||||
destination.hasRoute(HomeRoute::class) -> Messages
|
||||
destination.hasRoute(SearchRoute::class) -> Search
|
||||
destination.hasRoute(ActiveProfileRoute::class) -> Profile
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The route this item navigates to, or `null` when it cannot yet be built.
|
||||
*
|
||||
* Only [Profile] ever returns `null`, and only before the active profile's metadata
|
||||
* event has been read from the database. `ActiveProfileRoute` is addressed by event id
|
||||
* rather than by public key, so until that read lands there is no route to construct --
|
||||
* and an item that navigates nowhere is worse than one that is visibly disabled.
|
||||
*/
|
||||
fun route(activeUserPublicKey: String, activeProfileNostrEventId: String?): Route? =
|
||||
when (this) {
|
||||
Messages -> HomeRoute(activeUserPublicKey = activeUserPublicKey)
|
||||
Search -> SearchRoute(activeUserPublicKey = activeUserPublicKey)
|
||||
Profile -> activeProfileNostrEventId?.let {
|
||||
ActiveProfileRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
nostrEventId = it,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package press.mantra.compose.ui.composable.navigation
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ActiveProfileRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.HomeRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.SearchRoute
|
||||
import press.mantra.compose.ui.theme.Breakpoint
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* The navigation component per breakpoint, and the routes its three items lead to.
|
||||
*
|
||||
* The mapping is the kind of thing that is wrong in one row and right in every screenshot
|
||||
* anybody takes: the collapsed and expanded rails differ only in whether labels are drawn,
|
||||
* and nobody opens a 1200dp window on purpose.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
class NavigationSuiteTest {
|
||||
|
||||
private val publicKey = "de1a1e64d1c4e0d6bd97b0e73d4dfd0e1ec1cdd1e6d8d0e2b9a7f3c5d0e1a2b3"
|
||||
private val profileEventId = "aa11bb22cc33dd44ee55ff6600778899aabbccddeeff00112233445566778899"
|
||||
|
||||
@Test
|
||||
fun `each breakpoint takes the component the layout foundation gives it`() {
|
||||
// m3.material.io/foundations/layout/applying-layout/window-size-classes: navigation
|
||||
// bar under 600dp, collapsed rail through medium and expanded, expanded rail from
|
||||
// 1200dp. The library's own `NavigationSuiteScaffoldDefaults.navigationSuiteType`
|
||||
// cannot produce the last row -- it classifies with the three-value window size
|
||||
// class -- which is the whole reason this function exists.
|
||||
val expected = mapOf(
|
||||
Breakpoint.Compact to NavigationSuiteType.ShortNavigationBarCompact,
|
||||
Breakpoint.Medium to NavigationSuiteType.WideNavigationRailCollapsed,
|
||||
Breakpoint.Expanded to NavigationSuiteType.WideNavigationRailCollapsed,
|
||||
Breakpoint.Large to NavigationSuiteType.WideNavigationRailExpanded,
|
||||
Breakpoint.ExtraLarge to NavigationSuiteType.WideNavigationRailExpanded,
|
||||
)
|
||||
|
||||
assertEquals(Breakpoint.entries.size, expected.size, "a breakpoint has no component")
|
||||
expected.forEach { (breakpoint, component) ->
|
||||
assertEquals(component, navigationSuiteTypeFor(breakpoint, isTopLevel = true), "$breakpoint")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is drawn off a top-level destination, at any width`() {
|
||||
// The component belongs on the destinations it switches between. On a chat room, a
|
||||
// signing screen or an onboarding step -- reached by pushing a route and left by
|
||||
// coming back -- a persistent bar is an invitation to lose your place.
|
||||
Breakpoint.entries.forEach { breakpoint ->
|
||||
assertEquals(
|
||||
NavigationSuiteType.None,
|
||||
navigationSuiteTypeFor(breakpoint, isTopLevel = false),
|
||||
"$breakpoint drew a navigation component off a top-level destination",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every destination leads to the route it names`() {
|
||||
assertEquals(
|
||||
HomeRoute(activeUserPublicKey = publicKey),
|
||||
TopLevelDestination.Messages.route(publicKey, profileEventId),
|
||||
)
|
||||
assertEquals(
|
||||
SearchRoute(activeUserPublicKey = publicKey),
|
||||
TopLevelDestination.Search.route(publicKey, profileEventId),
|
||||
)
|
||||
assertEquals(
|
||||
ActiveProfileRoute(activeUserPublicKey = publicKey, nostrEventId = profileEventId),
|
||||
TopLevelDestination.Profile.route(publicKey, profileEventId),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only the profile item is unavailable before its event has been read`() {
|
||||
// `ActiveProfileRoute` is addressed by metadata event id rather than by public key,
|
||||
// so between signing in and that row arriving there is no route to build. The item
|
||||
// is disabled for those moments; the other two are never affected, which is what
|
||||
// stops a "wait for everything" guard being put around the whole component.
|
||||
assertNull(TopLevelDestination.Profile.route(publicKey, activeProfileNostrEventId = null))
|
||||
listOf(TopLevelDestination.Messages, TopLevelDestination.Search).forEach {
|
||||
assertEquals(
|
||||
it.route(publicKey, profileEventId),
|
||||
it.route(publicKey, activeProfileNostrEventId = null),
|
||||
"$it changed with the profile event id, which it does not use",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package press.mantra.compose.ui.composable.navigation
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotSelected
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.runDesktopComposeUiTest
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ActiveProfileRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.HomeRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.SearchRoute
|
||||
import press.mantra.compose.ui.theme.Breakpoint
|
||||
import press.mantra.compose.ui.theme.TorchTheme
|
||||
import kotlin.test.Test
|
||||
|
||||
/**
|
||||
* The navigation component composed around a real nav graph.
|
||||
*
|
||||
* [NavigationSuiteTest] asserts the mapping; this asserts that the pieces meet. Three
|
||||
* things here can only go wrong in a composition, and each of them fails silently:
|
||||
*
|
||||
* - `TopLevelDestination.of` matches by `NavDestination.hasRoute`, which is reflection
|
||||
* over the serialized route. A route renamed, or a `@Serializable` dropped, stops
|
||||
* matching, and the consequence is a navigation component that is simply never shown;
|
||||
* - which item reads as selected follows the same `hasRoute` match, so a graph whose
|
||||
* routes stopped matching would show a component with nothing selected in it;
|
||||
* - the profile item is disabled until its metadata event id arrives, and "disabled"
|
||||
* is a state that reads identically to "enabled" in source.
|
||||
*
|
||||
* The window is 1400 pixels at density 1, so the component is the expanded rail and the
|
||||
* three labels are drawn. In the compact bar they are drawn too, so the assertions hold
|
||||
* either way; the width is fixed so the test is not about the mapping.
|
||||
*
|
||||
* **Navigation is driven through the controller rather than by tapping an item.** Not a
|
||||
* preference: a click handler that navigates trips navigation-compose's own main-thread
|
||||
* assertion under `runDesktopComposeUiTest`, and it does so with twenty lines containing
|
||||
* no app code -- `NavHost`, two routes, and a `TextButton` that navigates. The harness
|
||||
* dispatches the click off the main thread and `LifecycleRegistry` refuses it. So what an
|
||||
* item's `onClick` builds is asserted in [NavigationSuiteTest], where it is a pure
|
||||
* function, and what the component does with a destination is asserted here.
|
||||
*/
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class NavigationSuiteRenderJvmTest {
|
||||
|
||||
private val publicKey = "de1a1e64d1c4e0d6bd97b0e73d4dfd0e1ec1cdd1e6d8d0e2b9a7f3c5d0e1a2b3"
|
||||
private val profileEventId = "aa11bb22cc33dd44ee55ff6600778899aabbccddeeff00112233445566778899"
|
||||
|
||||
@Test
|
||||
fun `the three items are shown on a top-level destination`() = runDesktopComposeUiTest(1400, 900) {
|
||||
setContent { Harness(profileNostrEventId = profileEventId) }
|
||||
|
||||
onNodeWithText("Messages").assertIsDisplayed()
|
||||
onNodeWithText("Search").assertIsDisplayed()
|
||||
onNodeWithText("Profile").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the item matching the current destination is the selected one`() =
|
||||
runDesktopComposeUiTest(1400, 900) {
|
||||
lateinit var controller: NavHostController
|
||||
setContent {
|
||||
controller = rememberNavController()
|
||||
Harness(profileNostrEventId = profileEventId, navController = controller)
|
||||
}
|
||||
|
||||
onNodeWithText("Messages").assertIsSelected()
|
||||
onNodeWithText("Search").assertIsNotSelected()
|
||||
|
||||
runOnIdle { controller.navigate(SearchRoute(publicKey)) }
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("search screen").assertIsDisplayed()
|
||||
onNodeWithText("Search").assertIsSelected()
|
||||
onNodeWithText("Messages").assertIsNotSelected()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is drawn once a pushed route is on top`() = runDesktopComposeUiTest(1400, 900) {
|
||||
lateinit var controller: NavHostController
|
||||
setContent {
|
||||
controller = rememberNavController()
|
||||
Harness(profileNostrEventId = profileEventId, navController = controller)
|
||||
}
|
||||
|
||||
onNodeWithText("Messages").assertIsDisplayed()
|
||||
|
||||
runOnIdle {
|
||||
controller.navigate(
|
||||
ChatRoomMessagingRoute(
|
||||
activeUserPublicKey = publicKey,
|
||||
chatRoomId = "room",
|
||||
relayHint = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("chat room").assertIsDisplayed()
|
||||
onNodeWithText("Messages").assertDoesNotExist()
|
||||
onNodeWithText("Profile").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the profile item waits for its event id, and the other two do not`() =
|
||||
runDesktopComposeUiTest(1400, 900) {
|
||||
setContent { Harness(profileNostrEventId = null) }
|
||||
|
||||
onNodeWithText("Profile").assertIsNotEnabled()
|
||||
onNodeWithText("Messages").assertIsEnabled()
|
||||
onNodeWithText("Search").assertIsEnabled()
|
||||
}
|
||||
|
||||
/**
|
||||
* The three top-level routes and one pushed route, each rendering only its own name.
|
||||
*
|
||||
* A stand-in for the app's graph rather than the graph itself: `MantraNavHost` builds
|
||||
* a database, six repositories and four view models on the way to its first screen,
|
||||
* and none of that decides whether the navigation component appears.
|
||||
*/
|
||||
@Composable
|
||||
private fun Harness(
|
||||
profileNostrEventId: String?,
|
||||
navController: NavHostController = rememberNavController(),
|
||||
) {
|
||||
TorchTheme {
|
||||
MantraNavigationSuite(
|
||||
navController = navController,
|
||||
breakpoint = Breakpoint.Large,
|
||||
activeUserPublicKey = publicKey,
|
||||
activeProfileNostrEventId = profileNostrEventId,
|
||||
) {
|
||||
NavHost(navController, startDestination = HomeRoute(publicKey)) {
|
||||
composable<HomeRoute> { Text("home screen") }
|
||||
composable<SearchRoute> { Text("search screen") }
|
||||
composable<ActiveProfileRoute> { Text("profile screen") }
|
||||
composable<ChatRoomMessagingRoute> { Text("chat room") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user