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:
Kgothatso Ngako
2026-09-08 07:53:27 +02:00
parent a53a9c3e11
commit 714354ae9a
9 changed files with 1303 additions and 826 deletions

View File

@@ -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)

View File

@@ -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>

View File

@@ -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

View File

@@ -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
}

View File

@@ -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,
)
}
}
}

View File

@@ -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",
)
}
}
}

View File

@@ -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") }
}
}
}
}
}