diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 2503f6bb..2a045ba7 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -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)
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 3f4ced0e..0a1dc303 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -158,6 +158,7 @@
Post
post functionality coming soon.
Private to you
+ Messages
Profile
Profile is ready
Profile keys
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt
index 6f2c68e7..50c0772a 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
index e7d147cd..4bed071e 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
@@ -6,7 +6,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import press.mantra.compose.AppLifecycle
@@ -17,6 +20,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.toRoute
import press.mantra.compose.MantraGlobal
import press.mantra.compose.database.repository.DatabaseChatRepository
@@ -98,6 +102,7 @@ import press.mantra.compose.ui.view.model.SynchronizationViewModel
import press.mantra.compose.ui.view.state.NavigationUIState
import press.mantra.compose.ui.view.state.NostrEventDetailUIState
import press.mantra.compose.ui.view.state.SearchUIState
+import press.mantra.compose.ui.theme.breakpoint
import co.touchlab.kermit.Logger
import fr.acinq.phoenix.PhoenixGlobal
import kotlinx.coroutines.CoroutineExceptionHandler
@@ -364,806 +369,832 @@ fun MantraNavHost(
}
}
- NavHost(
+ // The navigation component's three items, and the two facts it needs to build their
+ // routes.
+ //
+ // `activeUserPublicKey` is read off whichever top-level route is current -- all three
+ // carry it -- rather than held here, so it cannot drift from the screen underneath.
+ // The profile's metadata event id has no such source: `ActiveProfileRoute` is addressed
+ // by event id and only the home screen ever knew one, so it is observed here for as
+ // long as a key is signed in. One flow, cancelled and restarted when the key changes.
+ val currentBackStackEntry by navController.currentBackStackEntryAsState()
+ val activeUserPublicKey = currentBackStackEntry?.destination?.let { destination ->
+ when (TopLevelDestination.of(destination)) {
+ TopLevelDestination.Messages -> currentBackStackEntry?.toRoute()?.activeUserPublicKey
+ TopLevelDestination.Search -> currentBackStackEntry?.toRoute()?.activeUserPublicKey
+ TopLevelDestination.Profile -> currentBackStackEntry?.toRoute()?.activeUserPublicKey
+ null -> null
+ }
+ }
+
+ var activeProfileNostrEventId by remember { mutableStateOf(null) }
+ LaunchedEffect(activeUserPublicKey) {
+ val publicKey = activeUserPublicKey ?: return@LaunchedEffect
+ databaseNostrRepository.observeProfileWithFollowing(publicKey).collect { profile ->
+ activeProfileNostrEventId = profile?.nostrEvent?.id
+ }
+ }
+
+ MantraNavigationSuite(
navController = navController,
- startDestination = LoadingRoute()
+ breakpoint = MaterialTheme.breakpoint,
+ activeUserPublicKey = activeUserPublicKey,
+ activeProfileNostrEventId = activeProfileNostrEventId,
) {
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
+ NavHost(
+ navController = navController,
+ startDestination = LoadingRoute()
+ ) {
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
- SovereignWalletStartupScreen(
- sovereignWalletViewModel = sovereignWalletViewModel,
- // No seed on this device, so there is no wallet to start: the user has to make or
- // restore one, and that lives behind the landing screen. This used to park them on
- // a loading screen with nothing left to load.
- onNavigateToWalletLandingPage = {
- navController.navigate(
- route = LandingRoute
- ) {
- popUpTo(0)
- }
- },
- onSuccessfulStartup = {
- applicationIOScope.launch {
- navigationViewModel.loadNostrProfile(route)
- }
- },
- forceWalletId = null,
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- LoadingScreen(
- text = route.text
- )
- }
- composable {
- LandingScreen(
- onNavigateToLearnMore = {
- navController.navigate(
- route = ImplementationPendingRoute(
- "Learn more"
- )
- )
- },
- onNavigateToSignIn = {
- navController.navigate(
- route = SignInRoute
- )
- },
- onNavigateToCreateProfile = {
- navController.navigate(
- route = CreateProfileRoute()
- )
- }
- )
- }
- composable {
- CreateProfileScreen(
- onNavigateToEndThis = {
- navController.navigate(
- route = BlankRoute
- ) {
- popUpTo(0)
- }
- },
- nostrRepository = databaseNostrRepository,
- marmotRepository = databaseMarmotRepository,
- writeSeed = { words, onSeedWritten, onSeedWriteError ->
- sovereignWalletViewModel.writeSeed(
- words,
- isRestoringWallet = false,
- onSeedWritten = { walletId ->
- onSeedWritten()
-
- sovereignWalletViewModel.loadSovereignData(walletId)
- sovereignWalletViewModel.listAvailableWallets {
- sovereignWalletViewModel.switchToWallet(walletId)
- navController.navigate(
- route = SovereignWalletStartupRoute
- )
- }
- },
- onSeedWriteError = { onSeedWriteError() }
- )
- }
- )
-
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- ChatRoomCreationScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- onNavigateToRoute = { selectMembersRoute ->
- navController.navigate(
- selectMembersRoute
- )
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- SelectChatRoomMembersScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- name = route.name,
- description = route.description,
- nostrRepository = databaseNostrRepository,
- onNavigateToRoute = { selectChatRoomTypeRoute ->
- navController.navigate(
- selectChatRoomTypeRoute
- )
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- DkgRitualScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
- chatRepository = databaseChatRepository,
- dkgRepository = databaseDkgRepository,
- onNavigateToRoute = { approvalRoute ->
- navController.navigate(route = approvalRoute)
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- DkgJoinApprovalScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
- chatRepository = databaseChatRepository,
- dkgRepository = databaseDkgRepository,
- onDone = { navController.popBackStack() }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- DkgRound1ApprovalScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
- chatRepository = databaseChatRepository,
- dkgRepository = databaseDkgRepository,
- onDone = { navController.popBackStack() }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- DkgRound2ApprovalScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
- chatRepository = databaseChatRepository,
- dkgRepository = databaseDkgRepository,
- onDone = { navController.popBackStack() }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- SelectChatRoomTypeScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- name = route.name,
- description = route.description,
- memberPublicKeys = route.memberPublicKeys,
- activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
- nostrRepository = databaseNostrRepository,
- chatRepository = databaseChatRepository,
- dkgRepository = databaseDkgRepository,
- onNavigateToRoute = { chatRoomResultRoute ->
- navController.navigate(
- chatRoomResultRoute
- ) {
- // Drop every creation step: backing out of the new chat should land
- // on whatever the user was doing before, not back in the form.
- popUpTo(
- ChatRoomCreationRoute(
- activeUserPublicKey = route.activeUserPublicKey
- )
+ SovereignWalletStartupScreen(
+ sovereignWalletViewModel = sovereignWalletViewModel,
+ // No seed on this device, so there is no wallet to start: the user has to make or
+ // restore one, and that lives behind the landing screen. This used to park them on
+ // a loading screen with nothing left to load.
+ onNavigateToWalletLandingPage = {
+ navController.navigate(
+ route = LandingRoute
) {
- inclusive = true
+ popUpTo(0)
}
- }
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
- WriteNewNoteScreen(
- replyToNostrEventId = route.inReplyToEventId,
- activeUserPublicKey = route.activeUserPublicKey,
- quotedNostrEventId = route.quotedEventId,
- onNostrEventPublished = {
- applicationMainScope.launch {
- navController.popBackStack()
- }
- },
- onNavigateToNostrEvent = { hexKey ->
- navController.navigate(
- route = NostrEventDetailRoute(
- activeUserPublicKey = route.activeUserPublicKey,
- nostrEventId = hexKey
- )
- )
- },
- nostrRepository = databaseNostrRepository
- )
- }
- composable {
- SignInToProfileScreen(
- nostrRepository = databaseNostrRepository
- )
- }
- composable { backStackEntry ->
- backStackEntry.toRoute()
+ },
+ onSuccessfulStartup = {
+ applicationIOScope.launch {
+ navigationViewModel.loadNostrProfile(route)
+ }
+ },
+ forceWalletId = null,
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
- UnsignedProfileScreen()
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
- UnsyncedProfileScreen(
- unsyncedProfilePublicKey = route.publicKey
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
- UnqueuedProfileSynchronizationScreen(
- unsignedNostrEventId = route.unsignedNostrEventId,
- profilePublicKey = route.publicKey,
- nostrRepository = databaseNostrRepository
- )
- }
- composable {
- UnindexedProfileScreen()
- }
- composable {
- UnqueuedProfileScreen()
- }
- composable { backStackEntry ->
- backStackEntry.toRoute()
+ LoadingScreen(
+ text = route.text
+ )
+ }
+ composable {
+ LandingScreen(
+ onNavigateToLearnMore = {
+ navController.navigate(
+ route = ImplementationPendingRoute(
+ "Learn more"
+ )
+ )
+ },
+ onNavigateToSignIn = {
+ navController.navigate(
+ route = SignInRoute
+ )
+ },
+ onNavigateToCreateProfile = {
+ navController.navigate(
+ route = CreateProfileRoute()
+ )
+ }
+ )
+ }
+ composable {
+ CreateProfileScreen(
+ onNavigateToEndThis = {
+ navController.navigate(
+ route = BlankRoute
+ ) {
+ popUpTo(0)
+ }
+ },
+ nostrRepository = databaseNostrRepository,
+ marmotRepository = databaseMarmotRepository,
+ writeSeed = { words, onSeedWritten, onSeedWriteError ->
+ sovereignWalletViewModel.writeSeed(
+ words,
+ isRestoringWallet = false,
+ onSeedWritten = { walletId ->
+ onSeedWritten()
- UnannouncedProfileScreen()
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
- SocialPreconditionScreen(
- onNavigateToSkipForNow = {
- navController.navigate(
- route = HomeRoute(
- activeUserPublicKey = route.activeUserPubkey
+ sovereignWalletViewModel.loadSovereignData(walletId)
+ sovereignWalletViewModel.listAvailableWallets {
+ sovereignWalletViewModel.switchToWallet(walletId)
+ navController.navigate(
+ route = SovereignWalletStartupRoute
+ )
+ }
+ },
+ onSeedWriteError = { onSeedWriteError() }
)
- )
- },
- onNavigateToInviteFriend = {
- navController.navigate(
- route = ImplementationPendingRoute(
- "Invite friend"
- )
- )
- },
- onNavigateToViewInvites = {
- navController.navigate(
- route = ImplementationPendingRoute(
- "View invites"
- )
- )
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
- HomeScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- onNavigateToRoute = { eventRoute ->
- navController.navigate(
- route = eventRoute
- )
- },
- onNavigateToChatRoomCreation = {
- navController.navigate(
- route = ChatRoomCreationRoute(
- activeUserPublicKey = route.activeUserPublicKey
- )
- )
- },
- onNavigateToSearch = {
- navController.navigate(
- route = SearchRoute(
- activeUserPublicKey = route.activeUserPublicKey
- )
- )
- },
- onNavigateToDirectMessageDetail = { chatRoomDetailRoute ->
- navController.navigate(
- route = chatRoomDetailRoute
- )
- },
- nostrRepository = databaseNostrRepository,
- chatRepository = databaseChatRepository
- )
- }
- composable {
- Surface(
- modifier = Modifier.fillMaxSize(),
- // Nothing is drawn on it, so this is a backdrop rather than a surface
- // carrying content. `scrim` is the role for that, and is #000000 in every
- // one of this app's schemes -- so the pixels are unchanged and the value
- // now moves with the theme instead of standing outside it.
- color = MaterialTheme.colorScheme.scrim
- ) {
+ }
+ )
}
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
- ActiveProfileScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- nostrEventId = route.nostrEventId,
- nostrRepository = databaseNostrRepository,
- onNavigateBack = {
- navController.popBackStack()
- },
- onNavigateToRoute = { route ->
- navController.navigate(
- route = route
- )
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- ShareProfileScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- nostrEventId = route.nostrEventId,
- nostrRepository = databaseNostrRepository,
- onNavigateBack = {
- navController.popBackStack()
- },
- onNavigateToRoute = { route ->
- navController.navigate(
- route = route
- )
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- KeyPackageManagementScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- nostrEventId = route.nostrEventId,
- marmotRepository = databaseMarmotRepository,
- onNavigateBack = {
- navController.popBackStack()
- },
- onNavigateToRoute = { route ->
- navController.navigate(
- route = route
- )
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- ChatRoomMessagingScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- nostrRepository = databaseNostrRepository,
- chatRepository = databaseChatRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute ->
- navController.navigate(
- route = chatRoomDetailRoute
- ) {
- popUpTo(route) {
- inclusive = true
- }
- }
- },
- onNavigateToRoute = { actionRoute ->
- navController.navigate(
- route = actionRoute
- )
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- ChatRoomDetailScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- nostrRepository = databaseNostrRepository,
- chatRepository = databaseChatRepository,
- mantraRepository = databaseMantraRepository,
- onNavigateToRoute = { actionRoute ->
- navController.navigate(
- route = actionRoute
- )
- },
- onPopBackToRoute = { popRoute ->
- navController.popBackStack(
- route = popRoute,
- inclusive = false
- )
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- SearchMemberToAddToChatRoomScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- nostrRepository = databaseNostrRepository,
- chatRepository = databaseChatRepository,
- onNavigateToRoute = { chatRoomDetailRoute ->
- navController.navigate(
- route = chatRoomDetailRoute
- )
- },
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- AddMemberToChatRoomConfirmationScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- profilePublicKey = route.profilePublicKey,
- relayHint = route.relayHint,
- nostrRepository = databaseNostrRepository,
- chatRepository = databaseChatRepository,
- onInviteSent = {
- navController.popBackStack(
- route = SearchMemberToAddToChatRoomRoute(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint
- ),
- inclusive = true
- )
- },
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- AddArtifactScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- nostrRepository = databaseNostrRepository,
- chatRepository = databaseChatRepository,
- mantraRepository = databaseMantraRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive = { signingRoute ->
- // Replace this add screen so back returns to the group rather
- // than to a form whose proposal has already gone out.
- navController.navigate(
- route = signingRoute
- ) {
- popUpTo(route) {
- inclusive = true
- }
- }
- },
- onNavigateToRoute = { actionRoute ->
- navController.navigate(
- route = actionRoute
- )
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- AddDialectScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- nostrRepository = databaseNostrRepository,
- chatRepository = databaseChatRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive = { signingRoute ->
- // Replace this add screen so back returns to the group rather
- // than to a form whose proposal has already gone out.
- navController.navigate(route = signingRoute) {
- popUpTo {
- inclusive = true
- }
- }
- },
- onNavigateToRoute = { actionRoute ->
- navController.navigate(
- route = actionRoute
- )
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- FrostSigningScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- sessionId = route.sessionId,
- chatRepository = databaseChatRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- ProposalListScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chatRoomId = route.chatRoomId,
- chatRepository = databaseChatRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateBack = {
- navController.popBackStack()
- },
- onNavigateToRoute = { signingRoute ->
- navController.navigate(route = signingRoute)
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- ArtifactDetailScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- artifactId = route.artifactId,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- mantraRepository = databaseMantraRepository,
- onNavigateToRoute = { actionRoute ->
- navController.navigate(
- route = actionRoute
- )
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- ChapterDetailScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- chapterId = route.chapterId,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- mantraRepository = databaseMantraRepository,
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- TranslationArtifactVersionDetailScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- translationArtifactVersionId = route.translationArtifactVersionId,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- mantraRepository = databaseMantraRepository,
- chatRepository = databaseChatRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateToRoute = { actionRoute ->
- navController.navigate(
- route = actionRoute
- )
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- TranslationChapterScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- translationChapterId = route.translationChapterId,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- mantraRepository = databaseMantraRepository,
- onNavigateToRoute = { actionRoute ->
- navController.navigate(
- route = actionRoute
- )
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- TranslateChunkScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- translationChapterId = route.translationChapterId,
- chunkId = route.chunkId,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- mantraRepository = databaseMantraRepository,
- chatRepository = databaseChatRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive = { signingRoute ->
- // Replace this editor so back returns to the chapter table
- // rather than to a form whose proposal has already gone out.
- // The table itself is left alone: nothing is translated until
- // the group signs, so there is nothing new for it to show.
- navController.navigate(route = signingRoute) {
- popUpTo {
- inclusive = true
- }
- }
- },
- onNavigateToRoute = { actionRoute ->
- navController.navigate(route = actionRoute)
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- AddTranslationArtifactVersionScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- artifactId = route.artifactId,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- mantraRepository = databaseMantraRepository,
- chatRepository = databaseChatRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive = { signingRoute ->
- // Replace this screen so back returns to the artifact rather
- // than to a form whose proposal has already gone out.
- navController.navigate(route = signingRoute) {
- popUpTo {
- inclusive = true
- }
- }
- },
- onNavigateToRoute = { actionRoute ->
- navController.navigate(
- route = actionRoute
- )
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- AddChapterScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- artifactId = route.artifactId,
- chatRoomId = route.chatRoomId,
- relayHint = route.relayHint,
- mantraRepository = databaseMantraRepository,
- chatRepository = databaseChatRepository,
- frostSigningRepository = databaseFrostSigningRepository,
- onNavigateToRouteAndPopUpInclusive = { signingRoute ->
- // Replace this add screen so back returns to the artifact
- // rather than to a form whose proposal has already gone out.
- navController.navigate(route = signingRoute) {
- popUpTo {
- inclusive = true
- }
- }
- },
- onNavigateToRoute = { actionRoute ->
- navController.navigate(
- route = actionRoute
- )
- },
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- SearchScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- initialSearchUIState = SearchUIState.Prompt,
- nostrRepository = databaseNostrRepository,
- searchRepository = searchRepository,
- onNavigateToProfile = { route ->
- navController.navigate(
- route = route
- )
- },
- onNavigateToSearchResult = { route ->
- navController.navigate(
- route = route
- )
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- SearchResultScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- searchQuery = route.query,
- nostrRepository = databaseNostrRepository,
- searchRepository = searchRepository,
- onNavigateBack = {
- navController.popBackStack()
- },
- onNavigateToEvent = { route ->
- navController.navigate(
- route = route
- )
- }
- )
- }
- composable { backStackEntry ->
- val route = backStackEntry.toRoute()
-
- NostrEventDetailScreen(
- activeUserPublicKey = route.activeUserPublicKey,
- initialNostrEventDetailUIState = NostrEventDetailUIState.Loading,
- nostrEventId = route.nostrEventId,
- nostrRepository = databaseNostrRepository,
- onNavigateBack = {
- navController.popBackStack()
- },
- onNavigateToEvent = { route ->
- navController.navigate(
- route = route
- )
- },
- onNavigateToWriteAReply = { nostrEventId ->
- navController.navigate(
- route = WriteNewNoteRoute(
- activeUserPublicKey = route.activeUserPublicKey,
- inReplyToEventId = nostrEventId
+ ChatRoomCreationScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ onNavigateToRoute = { selectMembersRoute ->
+ navController.navigate(
+ selectMembersRoute
)
- )
- },
- onNavigateToEditProfile = {
- navController.navigate(
- route = ImplementationPendingRoute(
- "Edit profile"
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ SelectChatRoomMembersScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ name = route.name,
+ description = route.description,
+ nostrRepository = databaseNostrRepository,
+ onNavigateToRoute = { selectChatRoomTypeRoute ->
+ navController.navigate(
+ selectChatRoomTypeRoute
)
- )
- },
- onNavigateToQuoteNostrEvent = { nostrEventId ->
- navController.navigate(
- route = WriteNewNoteRoute(
- activeUserPublicKey = route.activeUserPublicKey,
- quotedEventId = nostrEventId
- )
- )
- },
- onNavigateToDirectMessage = { chatRoom ->
- navController.navigate(
- route = chatRoom
- ) {
- popUpTo(route) {
- inclusive = true
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ DkgRitualScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
+ chatRepository = databaseChatRepository,
+ dkgRepository = databaseDkgRepository,
+ onNavigateToRoute = { approvalRoute ->
+ navController.navigate(route = approvalRoute)
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ DkgJoinApprovalScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
+ chatRepository = databaseChatRepository,
+ dkgRepository = databaseDkgRepository,
+ onDone = { navController.popBackStack() }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ DkgRound1ApprovalScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
+ chatRepository = databaseChatRepository,
+ dkgRepository = databaseDkgRepository,
+ onDone = { navController.popBackStack() }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ DkgRound2ApprovalScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
+ chatRepository = databaseChatRepository,
+ dkgRepository = databaseDkgRepository,
+ onDone = { navController.popBackStack() }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ SelectChatRoomTypeScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ name = route.name,
+ description = route.description,
+ memberPublicKeys = route.memberPublicKeys,
+ activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
+ nostrRepository = databaseNostrRepository,
+ chatRepository = databaseChatRepository,
+ dkgRepository = databaseDkgRepository,
+ onNavigateToRoute = { chatRoomResultRoute ->
+ navController.navigate(
+ chatRoomResultRoute
+ ) {
+ // Drop every creation step: backing out of the new chat should land
+ // on whatever the user was doing before, not back in the form.
+ popUpTo(
+ ChatRoomCreationRoute(
+ activeUserPublicKey = route.activeUserPublicKey
+ )
+ ) {
+ inclusive = true
+ }
}
}
- },
- )
- }
- composable { backStackEntry ->
- val route: ImplementationPendingRoute = backStackEntry.toRoute()
- ImplementationPendingScreen(
- route.name
- )
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ WriteNewNoteScreen(
+ replyToNostrEventId = route.inReplyToEventId,
+ activeUserPublicKey = route.activeUserPublicKey,
+ quotedNostrEventId = route.quotedEventId,
+ onNostrEventPublished = {
+ applicationMainScope.launch {
+ navController.popBackStack()
+ }
+ },
+ onNavigateToNostrEvent = { hexKey ->
+ navController.navigate(
+ route = NostrEventDetailRoute(
+ activeUserPublicKey = route.activeUserPublicKey,
+ nostrEventId = hexKey
+ )
+ )
+ },
+ nostrRepository = databaseNostrRepository
+ )
+ }
+ composable {
+ SignInToProfileScreen(
+ nostrRepository = databaseNostrRepository
+ )
+ }
+ composable { backStackEntry ->
+ backStackEntry.toRoute()
+
+ UnsignedProfileScreen()
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ UnsyncedProfileScreen(
+ unsyncedProfilePublicKey = route.publicKey
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ UnqueuedProfileSynchronizationScreen(
+ unsignedNostrEventId = route.unsignedNostrEventId,
+ profilePublicKey = route.publicKey,
+ nostrRepository = databaseNostrRepository
+ )
+ }
+ composable {
+ UnindexedProfileScreen()
+ }
+ composable {
+ UnqueuedProfileScreen()
+ }
+ composable { backStackEntry ->
+ backStackEntry.toRoute()
+
+ UnannouncedProfileScreen()
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ SocialPreconditionScreen(
+ onNavigateToSkipForNow = {
+ navController.navigate(
+ route = HomeRoute(
+ activeUserPublicKey = route.activeUserPubkey
+ )
+ )
+ },
+ onNavigateToInviteFriend = {
+ navController.navigate(
+ route = ImplementationPendingRoute(
+ "Invite friend"
+ )
+ )
+ },
+ onNavigateToViewInvites = {
+ navController.navigate(
+ route = ImplementationPendingRoute(
+ "View invites"
+ )
+ )
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ HomeScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ onNavigateToRoute = { eventRoute ->
+ navController.navigate(
+ route = eventRoute
+ )
+ },
+ onNavigateToChatRoomCreation = {
+ navController.navigate(
+ route = ChatRoomCreationRoute(
+ activeUserPublicKey = route.activeUserPublicKey
+ )
+ )
+ },
+ onNavigateToDirectMessageDetail = { chatRoomDetailRoute ->
+ navController.navigate(
+ route = chatRoomDetailRoute
+ )
+ },
+ nostrRepository = databaseNostrRepository,
+ chatRepository = databaseChatRepository
+ )
+ }
+ composable {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ // Nothing is drawn on it, so this is a backdrop rather than a surface
+ // carrying content. `scrim` is the role for that, and is #000000 in every
+ // one of this app's schemes -- so the pixels are unchanged and the value
+ // now moves with the theme instead of standing outside it.
+ color = MaterialTheme.colorScheme.scrim
+ ) {
+
+ }
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ ActiveProfileScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ nostrEventId = route.nostrEventId,
+ nostrRepository = databaseNostrRepository,
+ onNavigateBack = {
+ navController.popBackStack()
+ },
+ onNavigateToRoute = { route ->
+ navController.navigate(
+ route = route
+ )
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ ShareProfileScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ nostrEventId = route.nostrEventId,
+ nostrRepository = databaseNostrRepository,
+ onNavigateBack = {
+ navController.popBackStack()
+ },
+ onNavigateToRoute = { route ->
+ navController.navigate(
+ route = route
+ )
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ KeyPackageManagementScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ nostrEventId = route.nostrEventId,
+ marmotRepository = databaseMarmotRepository,
+ onNavigateBack = {
+ navController.popBackStack()
+ },
+ onNavigateToRoute = { route ->
+ navController.navigate(
+ route = route
+ )
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ ChatRoomMessagingScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ nostrRepository = databaseNostrRepository,
+ chatRepository = databaseChatRepository,
+ frostSigningRepository = databaseFrostSigningRepository,
+ onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute ->
+ navController.navigate(
+ route = chatRoomDetailRoute
+ ) {
+ popUpTo(route) {
+ inclusive = true
+ }
+ }
+ },
+ onNavigateToRoute = { actionRoute ->
+ navController.navigate(
+ route = actionRoute
+ )
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ ChatRoomDetailScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ nostrRepository = databaseNostrRepository,
+ chatRepository = databaseChatRepository,
+ mantraRepository = databaseMantraRepository,
+ onNavigateToRoute = { actionRoute ->
+ navController.navigate(
+ route = actionRoute
+ )
+ },
+ onPopBackToRoute = { popRoute ->
+ navController.popBackStack(
+ route = popRoute,
+ inclusive = false
+ )
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ SearchMemberToAddToChatRoomScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ nostrRepository = databaseNostrRepository,
+ chatRepository = databaseChatRepository,
+ onNavigateToRoute = { chatRoomDetailRoute ->
+ navController.navigate(
+ route = chatRoomDetailRoute
+ )
+ },
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ AddMemberToChatRoomConfirmationScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ profilePublicKey = route.profilePublicKey,
+ relayHint = route.relayHint,
+ nostrRepository = databaseNostrRepository,
+ chatRepository = databaseChatRepository,
+ onInviteSent = {
+ navController.popBackStack(
+ route = SearchMemberToAddToChatRoomRoute(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint
+ ),
+ inclusive = true
+ )
+ },
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ AddArtifactScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ nostrRepository = databaseNostrRepository,
+ chatRepository = databaseChatRepository,
+ mantraRepository = databaseMantraRepository,
+ frostSigningRepository = databaseFrostSigningRepository,
+ onNavigateToRouteAndPopUpInclusive = { signingRoute ->
+ // Replace this add screen so back returns to the group rather
+ // than to a form whose proposal has already gone out.
+ navController.navigate(
+ route = signingRoute
+ ) {
+ popUpTo(route) {
+ inclusive = true
+ }
+ }
+ },
+ onNavigateToRoute = { actionRoute ->
+ navController.navigate(
+ route = actionRoute
+ )
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ AddDialectScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ nostrRepository = databaseNostrRepository,
+ chatRepository = databaseChatRepository,
+ frostSigningRepository = databaseFrostSigningRepository,
+ onNavigateToRouteAndPopUpInclusive = { signingRoute ->
+ // Replace this add screen so back returns to the group rather
+ // than to a form whose proposal has already gone out.
+ navController.navigate(route = signingRoute) {
+ popUpTo {
+ inclusive = true
+ }
+ }
+ },
+ onNavigateToRoute = { actionRoute ->
+ navController.navigate(
+ route = actionRoute
+ )
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ FrostSigningScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ sessionId = route.sessionId,
+ chatRepository = databaseChatRepository,
+ frostSigningRepository = databaseFrostSigningRepository,
+ onNavigateBack = {
+ navController.popBackStack()
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ ProposalListScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ chatRepository = databaseChatRepository,
+ frostSigningRepository = databaseFrostSigningRepository,
+ onNavigateBack = {
+ navController.popBackStack()
+ },
+ onNavigateToRoute = { signingRoute ->
+ navController.navigate(route = signingRoute)
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ ArtifactDetailScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ artifactId = route.artifactId,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ mantraRepository = databaseMantraRepository,
+ onNavigateToRoute = { actionRoute ->
+ navController.navigate(
+ route = actionRoute
+ )
+ },
+ onNavigateBack = {
+ navController.popBackStack()
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ ChapterDetailScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chapterId = route.chapterId,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ mantraRepository = databaseMantraRepository,
+ onNavigateBack = {
+ navController.popBackStack()
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ TranslationArtifactVersionDetailScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ translationArtifactVersionId = route.translationArtifactVersionId,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ mantraRepository = databaseMantraRepository,
+ chatRepository = databaseChatRepository,
+ frostSigningRepository = databaseFrostSigningRepository,
+ onNavigateToRoute = { actionRoute ->
+ navController.navigate(
+ route = actionRoute
+ )
+ },
+ onNavigateBack = {
+ navController.popBackStack()
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ TranslationChapterScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ translationChapterId = route.translationChapterId,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ mantraRepository = databaseMantraRepository,
+ onNavigateToRoute = { actionRoute ->
+ navController.navigate(
+ route = actionRoute
+ )
+ },
+ onNavigateBack = {
+ navController.popBackStack()
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ TranslateChunkScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ translationChapterId = route.translationChapterId,
+ chunkId = route.chunkId,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ mantraRepository = databaseMantraRepository,
+ chatRepository = databaseChatRepository,
+ frostSigningRepository = databaseFrostSigningRepository,
+ onNavigateToRouteAndPopUpInclusive = { signingRoute ->
+ // Replace this editor so back returns to the chapter table
+ // rather than to a form whose proposal has already gone out.
+ // The table itself is left alone: nothing is translated until
+ // the group signs, so there is nothing new for it to show.
+ navController.navigate(route = signingRoute) {
+ popUpTo {
+ inclusive = true
+ }
+ }
+ },
+ onNavigateToRoute = { actionRoute ->
+ navController.navigate(route = actionRoute)
+ },
+ onNavigateBack = {
+ navController.popBackStack()
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ AddTranslationArtifactVersionScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ artifactId = route.artifactId,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ mantraRepository = databaseMantraRepository,
+ chatRepository = databaseChatRepository,
+ frostSigningRepository = databaseFrostSigningRepository,
+ onNavigateToRouteAndPopUpInclusive = { signingRoute ->
+ // Replace this screen so back returns to the artifact rather
+ // than to a form whose proposal has already gone out.
+ navController.navigate(route = signingRoute) {
+ popUpTo {
+ inclusive = true
+ }
+ }
+ },
+ onNavigateToRoute = { actionRoute ->
+ navController.navigate(
+ route = actionRoute
+ )
+ },
+ onNavigateBack = {
+ navController.popBackStack()
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ AddChapterScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ artifactId = route.artifactId,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ mantraRepository = databaseMantraRepository,
+ chatRepository = databaseChatRepository,
+ frostSigningRepository = databaseFrostSigningRepository,
+ onNavigateToRouteAndPopUpInclusive = { signingRoute ->
+ // Replace this add screen so back returns to the artifact
+ // rather than to a form whose proposal has already gone out.
+ navController.navigate(route = signingRoute) {
+ popUpTo {
+ inclusive = true
+ }
+ }
+ },
+ onNavigateToRoute = { actionRoute ->
+ navController.navigate(
+ route = actionRoute
+ )
+ },
+ onNavigateBack = {
+ navController.popBackStack()
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ SearchScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ initialSearchUIState = SearchUIState.Prompt,
+ nostrRepository = databaseNostrRepository,
+ searchRepository = searchRepository,
+ onNavigateToProfile = { route ->
+ navController.navigate(
+ route = route
+ )
+ },
+ onNavigateToSearchResult = { route ->
+ navController.navigate(
+ route = route
+ )
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ SearchResultScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ searchQuery = route.query,
+ nostrRepository = databaseNostrRepository,
+ searchRepository = searchRepository,
+ onNavigateBack = {
+ navController.popBackStack()
+ },
+ onNavigateToEvent = { route ->
+ navController.navigate(
+ route = route
+ )
+ }
+ )
+ }
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ NostrEventDetailScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ initialNostrEventDetailUIState = NostrEventDetailUIState.Loading,
+ nostrEventId = route.nostrEventId,
+ nostrRepository = databaseNostrRepository,
+ onNavigateBack = {
+ navController.popBackStack()
+ },
+ onNavigateToEvent = { route ->
+ navController.navigate(
+ route = route
+ )
+ },
+ onNavigateToWriteAReply = { nostrEventId ->
+ navController.navigate(
+ route = WriteNewNoteRoute(
+ activeUserPublicKey = route.activeUserPublicKey,
+ inReplyToEventId = nostrEventId
+ )
+ )
+ },
+ onNavigateToEditProfile = {
+ navController.navigate(
+ route = ImplementationPendingRoute(
+ "Edit profile"
+ )
+ )
+ },
+ onNavigateToQuoteNostrEvent = { nostrEventId ->
+ navController.navigate(
+ route = WriteNewNoteRoute(
+ activeUserPublicKey = route.activeUserPublicKey,
+ quotedEventId = nostrEventId
+ )
+ )
+ },
+ onNavigateToDirectMessage = { chatRoom ->
+ navController.navigate(
+ route = chatRoom
+ ) {
+ popUpTo(route) {
+ inclusive = true
+ }
+ }
+ },
+ )
+ }
+ composable { backStackEntry ->
+ val route: ImplementationPendingRoute = backStackEntry.toRoute()
+ ImplementationPendingScreen(
+ route.name
+ )
+ }
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavigationSuite.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavigationSuite.kt
new file mode 100644
index 00000000..13129d20
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavigationSuite.kt
@@ -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 { 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
+ }
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/TopLevelDestination.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/TopLevelDestination.kt
new file mode 100644
index 00000000..9ce37302
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/TopLevelDestination.kt
@@ -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,
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteTest.kt
new file mode 100644
index 00000000..f899c5cc
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteTest.kt
@@ -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",
+ )
+ }
+ }
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteRenderJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteRenderJvmTest.kt
new file mode 100644
index 00000000..4082b769
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteRenderJvmTest.kt
@@ -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 { Text("home screen") }
+ composable { Text("search screen") }
+ composable { Text("profile screen") }
+ composable { Text("chat room") }
+ }
+ }
+ }
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 90e4013b..34c59f78 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
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-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "materialIconsExtended" }
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }