PeachAppNavHost.kt

18.8 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        navController.navigate(97            PeachNav.EntryEdit.of(98                id = null,99                prefillUrl = req.url,100                prefillUsername = req.username,101                prefillEmail = req.email,102            ),103        ) { launchSingleTop = true }104    }105    // Launcher shortcut / passkey-unlock action from the launch intent, held the106    // same way: every one of these destinations needs an open vault, and the107    // shortcut is usually tapped while the app is locked or not running.108    val pendingAction by PendingActionQueue.pending.collectAsStateWithLifecycle()109    LaunchedEffect(pendingAction, unlocked) {110        val action = pendingAction ?: return@LaunchedEffect111        if (!unlocked) return@LaunchedEffect112        PendingActionQueue.consume()113        val route = when (action) {114            PendingAction.Search -> PeachNav.VaultList.route115            PendingAction.AddEntry -> PeachNav.EntryEdit.of(id = null)116            PendingAction.Pair -> PeachNav.CodePair.route117            // Unlocking *is* the whole action: the credential provider re-queries118            // once the vault is open. Resuming the in-flight request itself needs119            // the provider request stashed across the unlock — out of scope here,120            // but the action is now recognised rather than silently dropped.121            PendingAction.PasskeyUnlock -> null122        } ?: return@LaunchedEffect123        navController.navigate(route) { launchSingleTop = true }124    }125126    val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle()    // Held until the vault is open: the changelog is harmless, but shoving it in127    // front of the lock screen reads as the app having lost its place.128    LaunchedEffect(whatsNewNotes, unlocked) {129        if (whatsNewNotes != null && unlocked &&130            navController.currentDestination?.route != PeachNav.WhatsNew.route131        ) {132            navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }133        }134    }135136    if (isUnlocked != null) {137        LaunchedEffect(unlocked, currentRoute) {138            if (!unlocked && requiresUnlockedVault(currentRoute)) {139                navController.navigate(PeachNav.Lock.route) {140                    popUpTo(0) { inclusive = true }141                    launchSingleTop = true142                }143            }144        }145    }146147    Box(Modifier.fillMaxSize()) {148        NavHost(149            navController = navController,150            startDestination = startDestination.route,151            enterTransition = {152                if (isEntryToEntryTransition(initialState.destination.route, targetState.destination.route)) {153                    fadeIn(tween(180))154                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {155                    slideInVertically(156                        initialOffsetY = { it },157                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),158                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))159                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {160                    EnterTransition.None161                } else {162                    slideInVertically(163                        initialOffsetY = { (it * 0.05f).toInt() },164                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),165                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))166                }167            },168            exitTransition = {169                if (isEntryToEntryTransition(initialState.destination.route, targetState.destination.route)) {170                    fadeOut(tween(180))171                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {172                    ExitTransition.None173                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {174                    slideOutVertically(175                        targetOffsetY = { it },176                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),177                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))178                } else {179                    slideOutVertically(180                        targetOffsetY = { -(it * 0.03f).toInt() },181                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),182                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))183                }184            },185            popEnterTransition = {186                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {187                    EnterTransition.None188                } else {189                    fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))190                }191            },192            popExitTransition = {193                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {194                    slideOutVertically(195                        targetOffsetY = { it },196                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),197                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))198                } else {199                    slideOutVertically(200                        targetOffsetY = { (it * 0.05f).toInt() },201                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),202                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))203                }204            },205        ) {206        composable(PeachNav.Onboarding.route) {207            OnboardingScreen(208                onFinished = {209                    navController.navigate(PeachNav.Lock.route)210                },211                onRestore = { nav(PeachNav.Import) },212                onAdopt = { nav(PeachNav.AdoptVault) },213            )214        }215        composable(PeachNav.Lock.route) {216            LockScreen(217                onUnlocked = { dest ->218                    navController.navigate(dest.route) {219                        popUpTo(0) { inclusive = true }220                    }221                },222                onNavigate = nav,223            )224        }225        composable(PeachNav.VaultList.route) {226            // C1: non-null only when the list was pushed on top of an entry227            // (slide-up-to-open from EntryDetailSearchStrip) — slide back228            // down pops back to that exact entry instance.229            val hasOrigin = navController.previousBackStackEntry != null230            VaultListScreen(231                onNavigate = nav,232                onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,233            )234        }235        // No {id}: NotesModeScreen opens on its table of contents, which is the236        // actual notes surface. The notes filter on the vault list is a filter,237        // not this.238        composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back) }239240        composable(241            route = PeachNav.EntryDetail.ROUTE,242            arguments = listOf(navArgument("id") { type = NavType.StringType }),243        ) { backStack ->244            // C1: when this entry is the post-unlock landing surface, its245            // unlock navigation popped Lock off the stack (see the Lock246            // composable below), so there's no previous entry — it's the247            // deck's root, not a screen pushed from the vault list, and248            // shouldn't offer a "back to list" affordance that implies249            // otherwise. Only entries reached by tapping a vault-list row250            // (a real push) get a real back target.251            val isRoot = navController.previousBackStackEntry == null252            EntryDetailScreen(253                id = backStack.arguments?.getString("id").orEmpty(),254                onNavigate = nav,255                onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),256            )257        }258        composable(259            route = PeachNav.EntryEdit.ROUTE,260            arguments = listOf(261                navArgument("id") { type = NavType.StringType; nullable = true },262                navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },263                navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },264                navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },265            ),266        ) { backStack ->267            val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }268            if (editId == null) {269                EntryEditScreen(270                    id = null,271                    onBack = back,272                    onOpenNote = { noteId ->273                        navController.navigate(PeachNav.NotesMode.of(noteId)) {274                            popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }275                        }276                    },277                )278            } else {279                EntryDetailScreen(280                    id = editId,281                    onNavigate = nav,282                    onBack = back,283                )284            }285        }286287        composable(288            route = PeachNav.NotesMode.ROUTE,289            arguments = listOf(navArgument("id") { type = NavType.StringType }),290        ) {291            NotesModeScreen(onBack = back)292        }293        composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }294        composable(PeachNav.CodexGenerate.route) {295            CodexGenerateScreen(296                onBack = back,297                onSetupRecovery = { nav(PeachNav.RecoverySetup) },298                onUpgradePro = { nav(PeachNav.Settings) },299            )300        }301        composable(PeachNav.CodexRestore.route) {302            CodexRestoreScreen(303                onBack = back,304                onRestored = {305                    navController.navigate(PeachNav.VaultList.route) {306                        popUpTo(0) { inclusive = true }307                    }308                },309            )310        }311        composable(PeachNav.CodePair.route) {312            CodePairScreen(313                onBack = back,314                onOpenPro = { nav(PeachNav.Settings) },315            )316        }317        composable(PeachNav.AdoptVault.route) {318            val context = LocalContext.current319            AdoptVaultScreen(320                onBack = back,321                onComplete = {322                    context323                        .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)324                        .edit()325                        .putBoolean(ONBOARD_KEY, true)326                        .apply()327                    navController.navigate(PeachNav.VaultList.route) {328                        popUpTo(0) { inclusive = true }329                    }330                },331            )332        }333        composable(PeachNav.Health.route) {334            VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })335        }336        composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }337        composable(PeachNav.Import.route) {338            ImportScreen(339                onBack = back,340                onCodex = { nav(PeachNav.CodexRestore) },341                onVaultCreated = {342                    navController.navigate(PeachNav.RecoverySetup.route) {343                        popUpTo(0) { inclusive = true }344                    }345                },346            )347        }348        composable(PeachNav.Export.route) { ExportScreen(onBack = back) }349        composable(PeachNav.Panic.route) {350            PanicScreen(351                onBack = back,352                onDestroyed = {353                    navController.navigate(PeachNav.Lock.route) {354                        popUpTo(0) { inclusive = true }355                    }356                },357            )358        }359        composable(PeachNav.RecoverySetup.route) {360            RecoveryWordsScreen(361                requireConfirm = true,362                onDone = {363                    navController.navigate(PeachNav.VaultList.route) {364                        popUpTo(0) { inclusive = true }365                    }366                },367            )368        }369        composable(PeachNav.RecoveryView.route) {370            RecoveryWordsScreen(requireConfirm = false, onDone = back)371        }372        composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }373        composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }374        composable(PeachNav.RecoveryEntry.route) {375            RecoveryWordEntryScreen(376                onSuccess = {377                    navController.navigate(PeachNav.VaultList.route) {378                        popUpTo(0) { inclusive = true }379                    }380                },381                onBack = back,382                onRestore = { nav(PeachNav.Import) },383            )384        }385        }386        CopyFeedbackOverlay()387    }388}389390internal fun requiresUnlockedVault(route: String?): Boolean {391    if (route == null) return false392    return route !in setOf(393        PeachNav.Onboarding.route,394        PeachNav.Lock.route,395        PeachNav.AdoptVault.route,396        PeachNav.Import.route,397        PeachNav.CodexRestore.route,398        PeachNav.RecoveryEntry.route,399        PeachNav.Update.route,400    )401}402403internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {404    val fromEntry = isEntrySurfaceRoute(fromRoute)405    val toEntry = isEntrySurfaceRoute(toRoute)406    return if (reverse) {407        fromRoute == PeachNav.VaultList.route && toEntry408    } else {409        fromEntry && toRoute == PeachNav.VaultList.route410    }411}412413internal fun isEntrySurfaceRoute(route: String?): Boolean =414    route?.startsWith("entry/") == true || route?.startsWith("notes/") == true415416internal fun isEntryToEntryTransition(fromRoute: String?, toRoute: String?): Boolean =417    isEntrySurfaceRoute(fromRoute) && isEntrySurfaceRoute(toRoute) && fromRoute != toRoute