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