PeachAppNavHost.kt

19.7 KB · raw

1package com.peachpasswords.ui23import androidx.compose.animation.EnterTransition4import androidx.compose.animation.ExitTransition5import androidx.compose.animation.core.tween6import androidx.compose.animation.fadeIn7import androidx.compose.animation.fadeOut8import androidx.compose.animation.slideInVertically9import androidx.compose.animation.slideOutVertically10import androidx.compose.foundation.layout.Box11import androidx.compose.foundation.layout.fillMaxSize12import androidx.compose.runtime.Composable13import androidx.compose.runtime.LaunchedEffect14import androidx.compose.runtime.getValue15import androidx.compose.ui.Modifier16import androidx.compose.ui.platform.LocalContext17import androidx.hilt.navigation.compose.hiltViewModel18import androidx.lifecycle.compose.collectAsStateWithLifecycle19import androidx.navigation.NavHostController20import androidx.navigation.NavType21import androidx.navigation.compose.NavHost22import androidx.navigation.compose.composable23import androidx.navigation.compose.currentBackStackEntryAsState24import androidx.navigation.compose.rememberNavController25import androidx.navigation.navArgument26import com.peachpasswords.PendingAction27import com.peachpasswords.PendingActionQueue28import com.peachpasswords.autofill.AutofillNavRepository29import com.peachpasswords.autofill.AutofillNavViewModel30import com.peachpasswords.ui.codex.CodexGenerateScreen31import com.peachpasswords.ui.codex.CodexRestoreScreen32import com.peachpasswords.ui.components.CopyFeedbackOverlay33import com.peachpasswords.ui.lock.LockScreen34import com.peachpasswords.ui.onboarding.OnboardingScreen35import com.peachpasswords.ui.pairing.CodePairScreen36import com.peachpasswords.ui.pairing.AdoptVaultScreen37import com.peachpasswords.ui.onboarding.ONBOARD_KEY38import com.peachpasswords.ui.onboarding.ONBOARD_PREFS39import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen40import com.peachpasswords.ui.settings.PanicScreen41import com.peachpasswords.ui.settings.SettingsScreen42import com.peachpasswords.ui.vault.VaultHealthScreen43import com.peachpasswords.ui.theme.PeachMotion44import com.peachpasswords.ui.updates.UpdateScreen45import com.peachpasswords.ui.updates.UpdateViewModel46import com.peachpasswords.ui.updates.WhatsNewScreen47import com.peachpasswords.ui.vault.EntryDetailScreen48import com.peachpasswords.ui.vault.EntryEditScreen49import com.peachpasswords.ui.vault.ExportScreen50import com.peachpasswords.ui.vault.ImportScreen51import com.peachpasswords.ui.vault.NotesModeScreen52import com.peachpasswords.ui.vault.RecoveryWordsScreen53import com.peachpasswords.ui.vault.TrashScreen54import com.peachpasswords.ui.vault.VaultListScreen5556@Composable57fun PeachAppNavHost(58    navController: NavHostController = rememberNavController(),59    startDestination: PeachNav = PeachNav.Lock,60    isUnlocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,61    uiGateLocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,62    autofillNav: AutofillNavRepository = hiltViewModel<AutofillNavViewModel>().repo,63) {64    val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }65    val back: () -> Unit = { navController.popBackStack() }6667    // When the vault locks for any reason — manual lock button, idle auto-lock, or68    // background lock (CF-1/CF-2) — drop to the lock screen and clear history so no69    // post-unlock screen lingers behind it. Guarded so it never fires on the lock70    // or onboarding screens (where the vault is legitimately locked).71    val uiGated = uiGateLocked?.collectAsStateWithLifecycle()?.value ?: false72    val unlocked = if (isUnlocked == null) true else (isUnlocked.collectAsStateWithLifecycle().value && !uiGated)73    val currentBackStackEntry by navController.currentBackStackEntryAsState()74    val currentRoute = currentBackStackEntry?.destination?.route7576    // Update surfaces, both driven from outside the nav graph: a notification tap77    // (openRequested) and the once-per-version post-update changelog.78    val updateVm: UpdateViewModel = hiltViewModel()79    val openUpdateRequested by updateVm.openRequested.collectAsStateWithLifecycle()80    LaunchedEffect(openUpdateRequested) {81        if (openUpdateRequested) {82            updateVm.consumeOpenRequest()83            navController.navigate(PeachNav.Update.route) { launchSingleTop = true }84        }85    }8687    // "+ Create new entry" deep link from the autofill picker. Holds the request88    // until the vault is open (the user must be unlocked to save a new entry).89    // Mirrors UpdateRepository's request/consume shape so MainActivity and the90    // nav host stay loosely coupled.91    val newEntryRequest by autofillNav.newEntryRequest.collectAsStateWithLifecycle()92    LaunchedEffect(newEntryRequest, unlocked) {93        val req = newEntryRequest ?: return@LaunchedEffect94        if (!unlocked) return@LaunchedEffect95        autofillNav.consumeNewEntryRequest()96        // popUpTo Lock inclusive: don't leave the lock screen in the back97        // stack behind the new-entry editor (the back-press would land the98        // user back at the lock screen with the vault still open).99        navController.navigate(100            PeachNav.EntryEdit.of(101                id = null,102                prefillUrl = req.url,103                prefillUsername = req.username,104                prefillEmail = req.email,105            ),106        ) {107            launchSingleTop = true108            popUpTo(PeachNav.Lock.route) { inclusive = true }109        }110    }111    // Launcher shortcut / passkey-unlock action from the launch intent, held the112    // same way: every one of these destinations needs an open vault, and the113    // shortcut is usually tapped while the app is locked or not running.114    val pendingAction by PendingActionQueue.pending.collectAsStateWithLifecycle()115    LaunchedEffect(pendingAction, unlocked) {116        val action = pendingAction ?: return@LaunchedEffect117        if (!unlocked) return@LaunchedEffect118        PendingActionQueue.consume()119        val route = when (action) {120            PendingAction.Search -> PeachNav.VaultList.route121            PendingAction.AddEntry -> PeachNav.EntryEdit.of(id = null)122            PendingAction.Pair -> PeachNav.CodePair.route123            // Unlocking *is* the whole action: the credential provider re-queries124            // once the vault is open. Resuming the in-flight request itself needs125            // the provider request stashed across the unlock — out of scope here,126            // but the action is now recognised rather than silently dropped.127            PendingAction.PasskeyUnlock -> null128        } ?: return@LaunchedEffect129        navController.navigate(route) { launchSingleTop = true }130    }131132    val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle()    // Held until the vault is open: the changelog is harmless, but shoving it in133    // front of the lock screen reads as the app having lost its place.134    LaunchedEffect(whatsNewNotes, unlocked) {135        if (whatsNewNotes != null && unlocked &&136            navController.currentDestination?.route != PeachNav.WhatsNew.route137        ) {138            navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }139        }140    }141142    if (isUnlocked != null) {143        LaunchedEffect(unlocked, currentRoute) {144            if (!unlocked && requiresUnlockedVault(currentRoute)) {145                navController.navigate(PeachNav.Lock.route) {146                    popUpTo(0) { inclusive = true }147                    launchSingleTop = true148                }149            }150        }151    }152153    Box(Modifier.fillMaxSize()) {154        NavHost(155            navController = navController,156            startDestination = startDestination.route,157            enterTransition = {158                if (isEntryToEntryTransition(initialState.destination.route, targetState.destination.route)) {159                    fadeIn(tween(180))160                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {161                    slideInVertically(162                        initialOffsetY = { it },163                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),164                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))165                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {166                    EnterTransition.None167                } else {168                    slideInVertically(169                        initialOffsetY = { (it * 0.05f).toInt() },170                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),171                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))172                }173            },174            exitTransition = {175                if (isEntryToEntryTransition(initialState.destination.route, targetState.destination.route)) {176                    fadeOut(tween(180))177                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {178                    ExitTransition.None179                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {180                    slideOutVertically(181                        targetOffsetY = { it },182                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),183                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))184                } else {185                    slideOutVertically(186                        targetOffsetY = { -(it * 0.03f).toInt() },187                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),188                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))189                }190            },191            popEnterTransition = {192                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {193                    EnterTransition.None194                } else {195                    fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))196                }197            },198            popExitTransition = {199                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {200                    slideOutVertically(201                        targetOffsetY = { it },202                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),203                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))204                } else {205                    slideOutVertically(206                        targetOffsetY = { (it * 0.05f).toInt() },207                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),208                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))209                }210            },211        ) {212        composable(PeachNav.Onboarding.route) {213            OnboardingScreen(214                onFinished = {215                    navController.navigate(PeachNav.Lock.route)216                },217                onRestore = { nav(PeachNav.Import) },218                onAdopt = { nav(PeachNav.AdoptVault) },219            )220        }221        composable(PeachNav.Lock.route) {222            LockScreen(223                onUnlocked = { dest ->224                    navController.navigate(dest.route) {225                        popUpTo(0) { inclusive = true }226                    }227                },228                onNavigate = nav,229                // When an autofill nav request is pending, LockScreen must not230                // also route to the last-viewed entry (see LaunchedEffect below).231                autofillPending = newEntryRequest != null,232            )233        }234        composable(PeachNav.VaultList.route) {235            // C1: non-null only when the list was pushed on top of an entry236            // (slide-up-to-open from EntryDetailSearchStrip) — slide back237            // down pops back to that exact entry instance.238            val hasOrigin = navController.previousBackStackEntry != null239            VaultListScreen(240                onNavigate = nav,241                onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,242            )243        }244        composable(PeachNav.VaultSearch.route) {245            VaultListScreen(246                onNavigate = nav,247                onBack = { navController.popBackStack() },248                initialSearchActive = true,249            )250        }251        // No {id}: NotesModeScreen opens on its table of contents, which is the252        // actual notes surface. The notes filter on the vault list is a filter,253        // not this.254        composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back, onNavigate = nav) }255256        composable(257            route = PeachNav.EntryDetail.ROUTE,258            arguments = listOf(navArgument("id") { type = NavType.StringType }),259        ) { backStack ->260            // C1: when this entry is the post-unlock landing surface, its261            // unlock navigation popped Lock off the stack (see the Lock262            // composable below), so there's no previous entry — it's the263            // deck's root, not a screen pushed from the vault list, and264            // shouldn't offer a "back to list" affordance that implies265            // otherwise. Only entries reached by tapping a vault-list row266            // (a real push) get a real back target.267            val isRoot = navController.previousBackStackEntry == null268            EntryDetailScreen(269                id = backStack.arguments?.getString("id").orEmpty(),270                onNavigate = nav,271                onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),272            )273        }274        composable(275            route = PeachNav.EntryEdit.ROUTE,276            arguments = listOf(277                navArgument("id") { type = NavType.StringType; nullable = true },278                navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },279                navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },280                navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },281            ),282        ) { backStack ->283            val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }284            if (editId == null) {285                EntryEditScreen(286                    id = null,287                    onBack = back,288                    onHome = { nav(PeachNav.VaultList) },289                    onOpenNote = { noteId ->290                        navController.navigate(PeachNav.NotesMode.of(noteId)) {291                            popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }292                        }293                    },294                )295            } else {296                EntryEditScreen(297                    id = editId,298                    onBack = back,299                    onHome = { nav(PeachNav.VaultList) },300                    onOpenNote = { noteId -> nav(PeachNav.NotesMode(noteId)) },301                )302            }303        }304305        composable(306            route = PeachNav.NotesMode.ROUTE,307            arguments = listOf(navArgument("id") { type = NavType.StringType }),308        ) {309            NotesModeScreen(onBack = back, onNavigate = nav)310        }311        composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }312        composable(PeachNav.CodexGenerate.route) {313            CodexGenerateScreen(314                onBack = back,315                onSetupRecovery = { nav(PeachNav.RecoverySetup) },316                onUpgradePro = { nav(PeachNav.Settings) },317            )318        }319        composable(PeachNav.CodexRestore.route) {320            CodexRestoreScreen(321                onBack = back,322                onRestored = {323                    navController.navigate(PeachNav.VaultList.route) {324                        popUpTo(0) { inclusive = true }325                    }326                },327            )328        }329        composable(PeachNav.CodePair.route) {330            CodePairScreen(331                onBack = back,332                onOpenPro = { nav(PeachNav.Settings) },333            )334        }335        composable(PeachNav.AdoptVault.route) {336            val context = LocalContext.current337            AdoptVaultScreen(338                onBack = back,339                onComplete = {340                    context341                        .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)342                        .edit()343                        .putBoolean(ONBOARD_KEY, true)344                        .apply()345                    navController.navigate(PeachNav.VaultList.route) {346                        popUpTo(0) { inclusive = true }347                    }348                },349            )350        }351        composable(PeachNav.Health.route) {352            VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })353        }354        composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }355        composable(PeachNav.Import.route) {356            ImportScreen(357                onBack = back,358                onCodex = { nav(PeachNav.CodexRestore) },359                onVaultCreated = {360                    navController.navigate(PeachNav.RecoverySetup.route) {361                        popUpTo(0) { inclusive = true }362                    }363                },364            )365        }366        composable(PeachNav.Export.route) { ExportScreen(onBack = back) }367        composable(PeachNav.Panic.route) {368            PanicScreen(369                onBack = back,370                onDestroyed = {371                    navController.navigate(PeachNav.Lock.route) {372                        popUpTo(0) { inclusive = true }373                    }374                },375            )376        }377        composable(PeachNav.RecoverySetup.route) {378            RecoveryWordsScreen(379                requireConfirm = true,380                onDone = {381                    navController.navigate(PeachNav.VaultList.route) {382                        popUpTo(0) { inclusive = true }383                    }384                },385            )386        }387        composable(PeachNav.RecoveryView.route) {388            RecoveryWordsScreen(requireConfirm = false, onDone = back)389        }390        composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }391        composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }392        composable(PeachNav.RecoveryEntry.route) {393            RecoveryWordEntryScreen(394                onSuccess = {395                    navController.navigate(PeachNav.VaultList.route) {396                        popUpTo(0) { inclusive = true }397                    }398                },399                onBack = back,400                onRestore = { nav(PeachNav.Import) },401            )402        }403        }404        CopyFeedbackOverlay()405    }406}407408internal fun requiresUnlockedVault(route: String?): Boolean {409    if (route == null) return false410    return route !in setOf(411        PeachNav.Onboarding.route,412        PeachNav.Lock.route,413        PeachNav.AdoptVault.route,414        PeachNav.Import.route,415        PeachNav.CodexRestore.route,416        PeachNav.RecoveryEntry.route,417        PeachNav.Update.route,418    )419}420421internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {422    val fromEntry = isEntrySurfaceRoute(fromRoute)423    val toEntry = isEntrySurfaceRoute(toRoute)424    return if (reverse) {425        fromRoute == PeachNav.VaultList.route && toEntry426    } else {427        fromEntry && toRoute == PeachNav.VaultList.route428    }429}430431internal fun isEntrySurfaceRoute(route: String?): Boolean =432    route?.startsWith("entry/") == true || route?.startsWith("notes/") == true433434internal fun isEntryToEntryTransition(fromRoute: String?, toRoute: String?): Boolean =435    isEntrySurfaceRoute(fromRoute) && isEntrySurfaceRoute(toRoute) && fromRoute != toRoute