package com.peachpasswords.ui import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.peachpasswords.PendingAction import com.peachpasswords.PendingActionQueue import com.peachpasswords.autofill.AutofillNavRepository import com.peachpasswords.autofill.AutofillNavViewModel import com.peachpasswords.ui.codex.CodexGenerateScreen import com.peachpasswords.ui.codex.CodexRestoreScreen import com.peachpasswords.ui.components.CopyFeedbackOverlay import com.peachpasswords.ui.lock.LockScreen import com.peachpasswords.ui.onboarding.OnboardingScreen import com.peachpasswords.ui.pairing.CodePairScreen import com.peachpasswords.ui.pairing.AdoptVaultScreen import com.peachpasswords.ui.onboarding.ONBOARD_KEY import com.peachpasswords.ui.onboarding.ONBOARD_PREFS import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen import com.peachpasswords.ui.settings.PanicScreen import com.peachpasswords.ui.settings.SettingsScreen import com.peachpasswords.ui.vault.VaultHealthScreen import com.peachpasswords.ui.theme.PeachMotion import com.peachpasswords.ui.updates.UpdateScreen import com.peachpasswords.ui.updates.UpdateViewModel import com.peachpasswords.ui.updates.WhatsNewScreen import com.peachpasswords.ui.vault.EntryDetailScreen import com.peachpasswords.ui.vault.EntryEditScreen import com.peachpasswords.ui.vault.ExportScreen import com.peachpasswords.ui.vault.ImportScreen import com.peachpasswords.ui.vault.NotesModeScreen import com.peachpasswords.ui.vault.RecoveryWordsScreen import com.peachpasswords.ui.vault.TrashScreen import com.peachpasswords.ui.vault.VaultListScreen @Composable fun PeachAppNavHost( navController: NavHostController = rememberNavController(), startDestination: PeachNav = PeachNav.Lock, isUnlocked: kotlinx.coroutines.flow.StateFlow? = null, uiGateLocked: kotlinx.coroutines.flow.StateFlow? = null, autofillNav: AutofillNavRepository = hiltViewModel().repo, ) { val nav: (PeachNav) -> Unit = { navController.navigate(it.route) } val back: () -> Unit = { navController.popBackStack() } // When the vault locks for any reason — manual lock button, idle auto-lock, or // background lock (CF-1/CF-2) — drop to the lock screen and clear history so no // post-unlock screen lingers behind it. Guarded so it never fires on the lock // or onboarding screens (where the vault is legitimately locked). val uiGated = uiGateLocked?.collectAsStateWithLifecycle()?.value ?: false val unlocked = if (isUnlocked == null) true else (isUnlocked.collectAsStateWithLifecycle().value && !uiGated) val currentBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = currentBackStackEntry?.destination?.route // Update surfaces, both driven from outside the nav graph: a notification tap // (openRequested) and the once-per-version post-update changelog. val updateVm: UpdateViewModel = hiltViewModel() val openUpdateRequested by updateVm.openRequested.collectAsStateWithLifecycle() LaunchedEffect(openUpdateRequested) { if (openUpdateRequested) { updateVm.consumeOpenRequest() navController.navigate(PeachNav.Update.route) { launchSingleTop = true } } } // "+ Create new entry" deep link from the autofill picker. Holds the request // until the vault is open (the user must be unlocked to save a new entry). // Mirrors UpdateRepository's request/consume shape so MainActivity and the // nav host stay loosely coupled. val newEntryRequest by autofillNav.newEntryRequest.collectAsStateWithLifecycle() LaunchedEffect(newEntryRequest, unlocked) { val req = newEntryRequest ?: return@LaunchedEffect if (!unlocked) return@LaunchedEffect autofillNav.consumeNewEntryRequest() // popUpTo Lock inclusive: don't leave the lock screen in the back // stack behind the new-entry editor (the back-press would land the // user back at the lock screen with the vault still open). navController.navigate( PeachNav.EntryEdit.of( id = null, prefillUrl = req.url, prefillUsername = req.username, prefillEmail = req.email, ), ) { launchSingleTop = true popUpTo(PeachNav.Lock.route) { inclusive = true } } } // Launcher shortcut / passkey-unlock action from the launch intent, held the // same way: every one of these destinations needs an open vault, and the // shortcut is usually tapped while the app is locked or not running. val pendingAction by PendingActionQueue.pending.collectAsStateWithLifecycle() LaunchedEffect(pendingAction, unlocked) { val action = pendingAction ?: return@LaunchedEffect if (!unlocked) return@LaunchedEffect PendingActionQueue.consume() val route = when (action) { PendingAction.Search -> PeachNav.VaultList.route PendingAction.AddEntry -> PeachNav.EntryEdit.of(id = null) PendingAction.Pair -> PeachNav.CodePair.route // Unlocking *is* the whole action: the credential provider re-queries // once the vault is open. Resuming the in-flight request itself needs // the provider request stashed across the unlock — out of scope here, // but the action is now recognised rather than silently dropped. PendingAction.PasskeyUnlock -> null } ?: return@LaunchedEffect navController.navigate(route) { launchSingleTop = true } } val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle() // Held until the vault is open: the changelog is harmless, but shoving it in // front of the lock screen reads as the app having lost its place. LaunchedEffect(whatsNewNotes, unlocked) { if (whatsNewNotes != null && unlocked && navController.currentDestination?.route != PeachNav.WhatsNew.route ) { navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true } } } if (isUnlocked != null) { LaunchedEffect(unlocked, currentRoute) { if (!unlocked && requiresUnlockedVault(currentRoute)) { navController.navigate(PeachNav.Lock.route) { popUpTo(0) { inclusive = true } launchSingleTop = true } } } } Box(Modifier.fillMaxSize()) { NavHost( navController = navController, startDestination = startDestination.route, enterTransition = { if (isEntryToEntryTransition(initialState.destination.route, targetState.destination.route)) { fadeIn(tween(180)) } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) { slideInVertically( initialOffsetY = { it }, animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring), ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring)) } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) { EnterTransition.None } else { slideInVertically( initialOffsetY = { (it * 0.05f).toInt() }, animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring), ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring)) } }, exitTransition = { if (isEntryToEntryTransition(initialState.destination.route, targetState.destination.route)) { fadeOut(tween(180)) } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) { ExitTransition.None } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) { slideOutVertically( targetOffsetY = { it }, animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat), ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat)) } else { slideOutVertically( targetOffsetY = { -(it * 0.03f).toInt() }, animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat), ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat)) } }, popEnterTransition = { if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) { EnterTransition.None } else { fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring)) } }, popExitTransition = { if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) { slideOutVertically( targetOffsetY = { it }, animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat), ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat)) } else { slideOutVertically( targetOffsetY = { (it * 0.05f).toInt() }, animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat), ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat)) } }, ) { composable(PeachNav.Onboarding.route) { OnboardingScreen( onFinished = { navController.navigate(PeachNav.Lock.route) }, onRestore = { nav(PeachNav.Import) }, onAdopt = { nav(PeachNav.AdoptVault) }, ) } composable(PeachNav.Lock.route) { LockScreen( onUnlocked = { dest -> navController.navigate(dest.route) { popUpTo(0) { inclusive = true } } }, onNavigate = nav, // When an autofill nav request is pending, LockScreen must not // also route to the last-viewed entry (see LaunchedEffect below). autofillPending = newEntryRequest != null, ) } composable(PeachNav.VaultList.route) { // C1: non-null only when the list was pushed on top of an entry // (slide-up-to-open from EntryDetailSearchStrip) — slide back // down pops back to that exact entry instance. val hasOrigin = navController.previousBackStackEntry != null VaultListScreen( onNavigate = nav, onBack = if (hasOrigin) ({ navController.popBackStack() }) else null, ) } composable(PeachNav.VaultSearch.route) { VaultListScreen( onNavigate = nav, onBack = { navController.popBackStack() }, initialSearchActive = true, ) } // No {id}: NotesModeScreen opens on its table of contents, which is the // actual notes surface. The notes filter on the vault list is a filter, // not this. composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back, onNavigate = nav) } composable( route = PeachNav.EntryDetail.ROUTE, arguments = listOf(navArgument("id") { type = NavType.StringType }), ) { backStack -> // C1: when this entry is the post-unlock landing surface, its // unlock navigation popped Lock off the stack (see the Lock // composable below), so there's no previous entry — it's the // deck's root, not a screen pushed from the vault list, and // shouldn't offer a "back to list" affordance that implies // otherwise. Only entries reached by tapping a vault-list row // (a real push) get a real back target. val isRoot = navController.previousBackStackEntry == null EntryDetailScreen( id = backStack.arguments?.getString("id").orEmpty(), onNavigate = nav, onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }), ) } composable( route = PeachNav.EntryEdit.ROUTE, arguments = listOf( navArgument("id") { type = NavType.StringType; nullable = true }, navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null }, navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null }, navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null }, ), ) { backStack -> val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() } if (editId == null) { EntryEditScreen( id = null, onBack = back, onOpenNote = { noteId -> navController.navigate(PeachNav.NotesMode.of(noteId)) { popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true } } }, ) } else { EntryEditScreen( id = editId, onBack = back, onOpenNote = { noteId -> nav(PeachNav.NotesMode(noteId)) }, ) } } composable( route = PeachNav.NotesMode.ROUTE, arguments = listOf(navArgument("id") { type = NavType.StringType }), ) { NotesModeScreen(onBack = back, onNavigate = nav) } composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) } composable(PeachNav.CodexGenerate.route) { CodexGenerateScreen( onBack = back, onSetupRecovery = { nav(PeachNav.RecoverySetup) }, onUpgradePro = { nav(PeachNav.Settings) }, ) } composable(PeachNav.CodexRestore.route) { CodexRestoreScreen( onBack = back, onRestored = { navController.navigate(PeachNav.VaultList.route) { popUpTo(0) { inclusive = true } } }, ) } composable(PeachNav.CodePair.route) { CodePairScreen( onBack = back, onOpenPro = { nav(PeachNav.Settings) }, ) } composable(PeachNav.AdoptVault.route) { val context = LocalContext.current AdoptVaultScreen( onBack = back, onComplete = { context .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE) .edit() .putBoolean(ONBOARD_KEY, true) .apply() navController.navigate(PeachNav.VaultList.route) { popUpTo(0) { inclusive = true } } }, ) } composable(PeachNav.Health.route) { VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) }) } composable(PeachNav.Trash.route) { TrashScreen(onBack = back) } composable(PeachNav.Import.route) { ImportScreen( onBack = back, onCodex = { nav(PeachNav.CodexRestore) }, onVaultCreated = { navController.navigate(PeachNav.RecoverySetup.route) { popUpTo(0) { inclusive = true } } }, ) } composable(PeachNav.Export.route) { ExportScreen(onBack = back) } composable(PeachNav.Panic.route) { PanicScreen( onBack = back, onDestroyed = { navController.navigate(PeachNav.Lock.route) { popUpTo(0) { inclusive = true } } }, ) } composable(PeachNav.RecoverySetup.route) { RecoveryWordsScreen( requireConfirm = true, onDone = { navController.navigate(PeachNav.VaultList.route) { popUpTo(0) { inclusive = true } } }, ) } composable(PeachNav.RecoveryView.route) { RecoveryWordsScreen(requireConfirm = false, onDone = back) } composable(PeachNav.Update.route) { UpdateScreen(onBack = back) } composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) } composable(PeachNav.RecoveryEntry.route) { RecoveryWordEntryScreen( onSuccess = { navController.navigate(PeachNav.VaultList.route) { popUpTo(0) { inclusive = true } } }, onBack = back, onRestore = { nav(PeachNav.Import) }, ) } } CopyFeedbackOverlay() } } internal fun requiresUnlockedVault(route: String?): Boolean { if (route == null) return false return route !in setOf( PeachNav.Onboarding.route, PeachNav.Lock.route, PeachNav.AdoptVault.route, PeachNav.Import.route, PeachNav.CodexRestore.route, PeachNav.RecoveryEntry.route, PeachNav.Update.route, ) } internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean { val fromEntry = isEntrySurfaceRoute(fromRoute) val toEntry = isEntrySurfaceRoute(toRoute) return if (reverse) { fromRoute == PeachNav.VaultList.route && toEntry } else { fromEntry && toRoute == PeachNav.VaultList.route } } internal fun isEntrySurfaceRoute(route: String?): Boolean = route?.startsWith("entry/") == true || route?.startsWith("notes/") == true internal fun isEntryToEntryTransition(fromRoute: String?, toRoute: String?): Boolean = isEntrySurfaceRoute(fromRoute) && isEntrySurfaceRoute(toRoute) && fromRoute != toRoute