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:
1 parent
a53a9c3e11
commit
714354ae9a
9 files changed
+1303
-826
No files matched your search
@@ -87,6 +87,7 @@ kotlin {
|
|||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
implementation(libs.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
implementation(libs.compose.material3.adaptive)
|
implementation(libs.compose.material3.adaptive)
|
||||||
|
implementation(libs.compose.material3.adaptive.navigation.suite)
|
||||||
implementation (libs.compose.material.icons.core)
|
implementation (libs.compose.material.icons.core)
|
||||||
implementation (libs.compose.material.icons.extended)
|
implementation (libs.compose.material.icons.extended)
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
|
|||||||
@@ -158,6 +158,7 @@
|
|||||||
<string name="post">Post</string>
|
<string name="post">Post</string>
|
||||||
<string name="post_functionality_coming_soon">post functionality coming soon.</string>
|
<string name="post_functionality_coming_soon">post functionality coming soon.</string>
|
||||||
<string name="private_to_you">Private to you</string>
|
<string name="private_to_you">Private to you</string>
|
||||||
|
<string name="messages">Messages</string>
|
||||||
<string name="profile">Profile</string>
|
<string name="profile">Profile</string>
|
||||||
<string name="profile_is_ready">Profile is ready</string>
|
<string name="profile_is_ready">Profile is ready</string>
|
||||||
<string name="profile_keys">Profile keys</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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -22,16 +19,14 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
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.database.model.intermdiate.LocalProfileWithFollowing
|
||||||
import press.mantra.compose.repository.ChatRepository
|
import press.mantra.compose.repository.ChatRepository
|
||||||
import press.mantra.compose.repository.NostrRepository
|
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.navigation.routes.Route
|
||||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
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.NewChatBottomSheetDialog
|
||||||
import press.mantra.compose.ui.composable.widgets.dialogs.StartDirectMessageToNpubOrNip05Dialog
|
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.theme.TorchTheme
|
||||||
import press.mantra.compose.ui.view.model.ChatRoomListViewModel
|
import press.mantra.compose.ui.view.model.ChatRoomListViewModel
|
||||||
import press.mantra.compose.ui.view.model.HomeScreenType
|
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 org.jetbrains.compose.resources.stringResource
|
||||||
import mantra.composeapp.generated.resources.mantra
|
import mantra.composeapp.generated.resources.mantra
|
||||||
import mantra.composeapp.generated.resources.new_chat
|
import mantra.composeapp.generated.resources.new_chat
|
||||||
import mantra.composeapp.generated.resources.something_went_wrong
|
|
||||||
import press.mantra.compose.ui.composable.widgets.ErrorState
|
import press.mantra.compose.ui.composable.widgets.ErrorState
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||||
@@ -75,7 +67,6 @@ fun HomeScreen(
|
|||||||
onNavigateToRoute: (Route) -> Unit,
|
onNavigateToRoute: (Route) -> Unit,
|
||||||
onNavigateToDirectMessageDetail: (Route) -> Unit,
|
onNavigateToDirectMessageDetail: (Route) -> Unit,
|
||||||
onNavigateToChatRoomCreation: () -> Unit,
|
onNavigateToChatRoomCreation: () -> Unit,
|
||||||
onNavigateToSearch: () -> Unit,
|
|
||||||
nostrRepository: NostrRepository,
|
nostrRepository: NostrRepository,
|
||||||
chatRepository: ChatRepository
|
chatRepository: ChatRepository
|
||||||
) {
|
) {
|
||||||
@@ -124,35 +115,13 @@ fun HomeScreen(
|
|||||||
// a content one.
|
// a content one.
|
||||||
Text(stringResource(Res.string.mantra))
|
Text(stringResource(Res.string.mantra))
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
// No navigation icon and no actions. The avatar in the leading
|
||||||
IconButton(
|
// slot and the search icon in the trailing one were the app's only
|
||||||
onClick = {
|
// two peer surfaces, and they are now items in the navigation
|
||||||
onNavigateToRoute.invoke(
|
// component -- a bar on a phone, a rail on a wide window. Leaving
|
||||||
ActiveProfileRoute(
|
// them here as well would be two routes to one destination, which
|
||||||
activeUserPublicKey = activeUserPublicKey,
|
// is the thing M3's "swap only functionally equivalent components"
|
||||||
nostrEventId = homeScreenUIState.profileWithFollowing.nostrEvent.id
|
// caution is about.
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
ProfileAvatar(
|
|
||||||
profile = homeScreenUIState.profileWithFollowing.profile,
|
|
||||||
publicKey = homeScreenUIState.profileWithFollowing.profile.publicKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
onNavigateToSearch.invoke()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Search,
|
|
||||||
contentDescription = "Search"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -305,7 +274,6 @@ It has survived not only five centuries, but also the leap into electronic types
|
|||||||
),
|
),
|
||||||
onNavigateToRoute = {},
|
onNavigateToRoute = {},
|
||||||
onNavigateToChatRoomCreation = {},
|
onNavigateToChatRoomCreation = {},
|
||||||
onNavigateToSearch = {},
|
|
||||||
onNavigateToDirectMessageDetail = {},
|
onNavigateToDirectMessageDetail = {},
|
||||||
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
|
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
|
||||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY
|
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY
|
||||||
|
|||||||
+816
-785
File diff suppressed because it is too large.
Load diff
+151
@@ -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
|
||||||
|
}
|
||||||
+80
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+150
@@ -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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,9 @@ compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.re
|
|||||||
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
|
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
|
||||||
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
|
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
|
||||||
compose-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "material3Adaptive" }
|
compose-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "material3Adaptive" }
|
||||||
|
# Versioned with material3 rather than with material3Adaptive: it is published by the
|
||||||
|
# material3 group and its 1.10.0-alpha05 is what names adaptive 1.2.0 in the first place.
|
||||||
|
compose-material3-adaptive-navigation-suite = { module = "org.jetbrains.compose.material3:material3-adaptive-navigation-suite", version.ref = "material3" }
|
||||||
compose-material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "materialIconsCore" }
|
compose-material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "materialIconsCore" }
|
||||||
compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "materialIconsExtended" }
|
compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "materialIconsExtended" }
|
||||||
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
|
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
|
||||||
|
|||||||
Reference in new issue
Block a user