PeachAppNavHost.kt

18.1 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    autofillNav: AutofillNavRepository = hiltViewModel<AutofillNavViewModel>().repo,62) {63    val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }64    val back: () -> Unit = { navController.popBackStack() }6566    // When the vault locks for any reason — manual lock button, idle auto-lock, or67    // background lock (CF-1/CF-2) — drop to the lock screen and clear history so no68    // post-unlock screen lingers behind it. Guarded so it never fires on the lock69    // or onboarding screens (where the vault is legitimately locked).70    val unlocked = if (isUnlocked == null) true else isUnlocked.collectAsStateWithLifecycle().value71    val currentBackStackEntry by navController.currentBackStackEntryAsState()72    val currentRoute = currentBackStackEntry?.destination?.route7374    // Update surfaces, both driven from outside the nav graph: a notification tap75    // (openRequested) and the once-per-version post-update changelog.76    val updateVm: UpdateViewModel = hiltViewModel()77    val openUpdateRequested by updateVm.openRequested.collectAsStateWithLifecycle()78    LaunchedEffect(openUpdateRequested) {79        if (openUpdateRequested) {80            updateVm.consumeOpenRequest()81            navController.navigate(PeachNav.Update.route) { launchSingleTop = true }82        }83    }8485    // "+ Create new entry" deep link from the autofill picker. Holds the request86    // until the vault is open (the user must be unlocked to save a new entry).87    // Mirrors UpdateRepository's request/consume shape so MainActivity and the88    // nav host stay loosely coupled.89    val newEntryRequest by autofillNav.newEntryRequest.collectAsStateWithLifecycle()90    LaunchedEffect(newEntryRequest, unlocked) {91        val req = newEntryRequest ?: return@LaunchedEffect92        if (!unlocked) return@LaunchedEffect93        autofillNav.consumeNewEntryRequest()94        navController.navigate(95            PeachNav.EntryEdit.of(96                id = null,97                prefillUrl = req.url,98                prefillUsername = req.username,99                prefillEmail = req.email,100            ),101        ) { launchSingleTop = true }102    }103    // Launcher shortcut / passkey-unlock action from the launch intent, held the104    // same way: every one of these destinations needs an open vault, and the105    // shortcut is usually tapped while the app is locked or not running.106    val pendingAction by PendingActionQueue.pending.collectAsStateWithLifecycle()107    LaunchedEffect(pendingAction, unlocked) {108        val action = pendingAction ?: return@LaunchedEffect109        if (!unlocked) return@LaunchedEffect110        PendingActionQueue.consume()111        val route = when (action) {112            PendingAction.Search -> PeachNav.VaultList.route113            PendingAction.AddEntry -> PeachNav.EntryEdit.of(id = null)114            PendingAction.Pair -> PeachNav.CodePair.route115            // Unlocking *is* the whole action: the credential provider re-queries116            // once the vault is open. Resuming the in-flight request itself needs117            // the provider request stashed across the unlock — out of scope here,118            // but the action is now recognised rather than silently dropped.119            PendingAction.PasskeyUnlock -> null120        } ?: return@LaunchedEffect121        navController.navigate(route) { launchSingleTop = true }122    }123124    val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle()    // Held until the vault is open: the changelog is harmless, but shoving it in125    // front of the lock screen reads as the app having lost its place.126    LaunchedEffect(whatsNewNotes, unlocked) {127        if (whatsNewNotes != null && unlocked &&128            navController.currentDestination?.route != PeachNav.WhatsNew.route129        ) {130            navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }131        }132    }133134    if (isUnlocked != null) {135        LaunchedEffect(unlocked, currentRoute) {136            if (!unlocked && requiresUnlockedVault(currentRoute)) {137                navController.navigate(PeachNav.Lock.route) {138                    popUpTo(0) { inclusive = true }139                    launchSingleTop = true140                }141            }142        }143    }144145    Box(Modifier.fillMaxSize()) {146        NavHost(147            navController = navController,148            startDestination = startDestination.route,149            enterTransition = {150                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {151                    slideInVertically(152                        initialOffsetY = { it },153                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),154                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))155                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {156                    EnterTransition.None157                } else {158                    slideInVertically(159                        initialOffsetY = { (it * 0.05f).toInt() },160                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),161                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))162                }163            },164            exitTransition = {165                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {166                    ExitTransition.None167                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {168                    slideOutVertically(169                        targetOffsetY = { it },170                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),171                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))172                } else {173                    slideOutVertically(174                        targetOffsetY = { -(it * 0.03f).toInt() },175                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),176                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))177                }178            },179            popEnterTransition = {180                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {181                    EnterTransition.None182                } else {183                    fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))184                }185            },186            popExitTransition = {187                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {188                    slideOutVertically(189                        targetOffsetY = { it },190                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),191                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))192                } else {193                    slideOutVertically(194                        targetOffsetY = { (it * 0.05f).toInt() },195                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),196                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))197                }198            },199        ) {200        composable(PeachNav.Onboarding.route) {201            OnboardingScreen(202                onFinished = {203                    navController.navigate(PeachNav.Lock.route)204                },205                onRestore = { nav(PeachNav.Import) },206                onAdopt = { nav(PeachNav.AdoptVault) },207            )208        }209        composable(PeachNav.Lock.route) {210            LockScreen(211                onUnlocked = { dest ->212                    navController.navigate(dest.route) {213                        popUpTo(0) { inclusive = true }214                    }215                },216                onNavigate = nav,217            )218        }219        composable(PeachNav.VaultList.route) {220            // C1: non-null only when the list was pushed on top of an entry221            // (slide-up-to-open from EntryDetailSearchStrip) — slide back222            // down pops back to that exact entry instance.223            val hasOrigin = navController.previousBackStackEntry != null224            VaultListScreen(225                onNavigate = nav,226                onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,227            )228        }229        // No {id}: NotesModeScreen opens on its table of contents, which is the230        // actual notes surface. The notes filter on the vault list is a filter,231        // not this.232        composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back) }233234        composable(235            route = PeachNav.EntryDetail.ROUTE,236            arguments = listOf(navArgument("id") { type = NavType.StringType }),237        ) { backStack ->238            // C1: when this entry is the post-unlock landing surface, its239            // unlock navigation popped Lock off the stack (see the Lock240            // composable below), so there's no previous entry — it's the241            // deck's root, not a screen pushed from the vault list, and242            // shouldn't offer a "back to list" affordance that implies243            // otherwise. Only entries reached by tapping a vault-list row244            // (a real push) get a real back target.245            val isRoot = navController.previousBackStackEntry == null246            EntryDetailScreen(247                id = backStack.arguments?.getString("id").orEmpty(),248                onNavigate = nav,249                onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),250            )251        }252        composable(253            route = PeachNav.EntryEdit.ROUTE,254            arguments = listOf(255                navArgument("id") { type = NavType.StringType; nullable = true },256                navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },257                navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },258                navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },259            ),260        ) { backStack ->261            val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }262            if (editId == null) {263                EntryEditScreen(264                    id = null,265                    onBack = back,266                    onOpenNote = { noteId ->267                        navController.navigate(PeachNav.NotesMode.of(noteId)) {268                            popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }269                        }270                    },271                )272            } else {273                EntryDetailScreen(274                    id = editId,275                    onNavigate = nav,276                    onBack = back,277                )278            }279        }280281        composable(282            route = PeachNav.NotesMode.ROUTE,283            arguments = listOf(navArgument("id") { type = NavType.StringType }),284        ) {285            NotesModeScreen(onBack = back)286        }287        composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }288        composable(PeachNav.CodexGenerate.route) {289            CodexGenerateScreen(290                onBack = back,291                onSetupRecovery = { nav(PeachNav.RecoverySetup) },292                onUpgradePro = { nav(PeachNav.Settings) },293            )294        }295        composable(PeachNav.CodexRestore.route) {296            CodexRestoreScreen(297                onBack = back,298                onRestored = {299                    navController.navigate(PeachNav.VaultList.route) {300                        popUpTo(0) { inclusive = true }301                    }302                },303            )304        }305        composable(PeachNav.CodePair.route) {306            CodePairScreen(307                onBack = back,308                onOpenPro = { nav(PeachNav.Settings) },309            )310        }311        composable(PeachNav.AdoptVault.route) {312            val context = LocalContext.current313            AdoptVaultScreen(314                onBack = back,315                onComplete = {316                    context317                        .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)318                        .edit()319                        .putBoolean(ONBOARD_KEY, true)320                        .apply()321                    navController.navigate(PeachNav.VaultList.route) {322                        popUpTo(0) { inclusive = true }323                    }324                },325            )326        }327        composable(PeachNav.Health.route) {328            VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })329        }330        composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }331        composable(PeachNav.Import.route) {332            ImportScreen(333                onBack = back,334                onCodex = { nav(PeachNav.CodexRestore) },335                onVaultCreated = {336                    navController.navigate(PeachNav.RecoverySetup.route) {337                        popUpTo(0) { inclusive = true }338                    }339                },340            )341        }342        composable(PeachNav.Export.route) { ExportScreen(onBack = back) }343        composable(PeachNav.Panic.route) {344            PanicScreen(345                onBack = back,346                onDestroyed = {347                    navController.navigate(PeachNav.Lock.route) {348                        popUpTo(0) { inclusive = true }349                    }350                },351            )352        }353        composable(PeachNav.RecoverySetup.route) {354            RecoveryWordsScreen(355                requireConfirm = true,356                onDone = {357                    navController.navigate(PeachNav.VaultList.route) {358                        popUpTo(0) { inclusive = true }359                    }360                },361            )362        }363        composable(PeachNav.RecoveryView.route) {364            RecoveryWordsScreen(requireConfirm = false, onDone = back)365        }366        composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }367        composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }368        composable(PeachNav.RecoveryEntry.route) {369            RecoveryWordEntryScreen(370                onSuccess = {371                    navController.navigate(PeachNav.VaultList.route) {372                        popUpTo(0) { inclusive = true }373                    }374                },375                onBack = back,376                onRestore = { nav(PeachNav.Import) },377            )378        }379        }380        CopyFeedbackOverlay()381    }382}383384internal fun requiresUnlockedVault(route: String?): Boolean {385    if (route == null) return false386    return route !in setOf(387        PeachNav.Onboarding.route,388        PeachNav.Lock.route,389        PeachNav.AdoptVault.route,390        PeachNav.Import.route,391        PeachNav.CodexRestore.route,392        PeachNav.RecoveryEntry.route,393        PeachNav.Update.route,394    )395}396397internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {398    val fromEntry = isEntrySurfaceRoute(fromRoute)399    val toEntry = isEntrySurfaceRoute(toRoute)400    return if (reverse) {401        fromRoute == PeachNav.VaultList.route && toEntry402    } else {403        fromEntry && toRoute == PeachNav.VaultList.route404    }405}406407internal fun isEntrySurfaceRoute(route: String?): Boolean =408    route?.startsWith("entry/") == true || route?.startsWith("notes/") == true