PeachAppNavHost.kt

15.5 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.hilt.navigation.compose.hiltViewModel17import androidx.lifecycle.compose.collectAsStateWithLifecycle18import androidx.navigation.NavHostController19import androidx.navigation.NavType20import androidx.navigation.compose.NavHost21import androidx.navigation.compose.composable22import androidx.navigation.compose.rememberNavController23import androidx.navigation.navArgument24import com.peachpasswords.autofill.AutofillNavRepository25import com.peachpasswords.autofill.AutofillNavViewModel26import com.peachpasswords.ui.codex.CodexGenerateScreen27import com.peachpasswords.ui.codex.CodexRestoreScreen28import com.peachpasswords.ui.components.CopyFeedbackOverlay29import com.peachpasswords.ui.lock.LockScreen30import com.peachpasswords.ui.onboarding.OnboardingScreen31import com.peachpasswords.ui.pairing.CodePairScreen32import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen33import com.peachpasswords.ui.settings.PanicScreen34import com.peachpasswords.ui.settings.SettingsScreen35import com.peachpasswords.ui.vault.VaultHealthScreen36import com.peachpasswords.ui.theme.PeachMotion37import com.peachpasswords.ui.updates.UpdateScreen38import com.peachpasswords.ui.updates.UpdateViewModel39import com.peachpasswords.ui.updates.WhatsNewScreen40import com.peachpasswords.ui.vault.EntryDetailScreen41import com.peachpasswords.ui.vault.EntryEditScreen42import com.peachpasswords.ui.vault.ExportScreen43import com.peachpasswords.ui.vault.ImportScreen44import com.peachpasswords.ui.vault.NotesModeScreen45import com.peachpasswords.ui.vault.RecoveryWordsScreen46import com.peachpasswords.ui.vault.TrashScreen47import com.peachpasswords.ui.vault.VaultListScreen4849@Composable50fun PeachAppNavHost(51    navController: NavHostController = rememberNavController(),52    startDestination: PeachNav = PeachNav.Lock,53    isUnlocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,54    autofillNav: AutofillNavRepository = hiltViewModel<AutofillNavViewModel>().repo,55) {56    val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }57    val back: () -> Unit = { navController.popBackStack() }5859    // When the vault locks for any reason — manual lock button, idle auto-lock, or60    // background lock (CF-1/CF-2) — drop to the lock screen and clear history so no61    // post-unlock screen lingers behind it. Guarded so it never fires on the lock62    // or onboarding screens (where the vault is legitimately locked).63    val unlocked = if (isUnlocked == null) true else isUnlocked.collectAsStateWithLifecycle().value6465    // Update surfaces, both driven from outside the nav graph: a notification tap66    // (openRequested) and the once-per-version post-update changelog.67    val updateVm: UpdateViewModel = hiltViewModel()68    val openUpdateRequested by updateVm.openRequested.collectAsStateWithLifecycle()69    LaunchedEffect(openUpdateRequested) {70        if (openUpdateRequested) {71            updateVm.consumeOpenRequest()72            navController.navigate(PeachNav.Update.route) { launchSingleTop = true }73        }74    }7576    // "+ Create new entry" deep link from the autofill picker. Holds the request77    // until the vault is open (the user must be unlocked to save a new entry).78    // Mirrors UpdateRepository's request/consume shape so MainActivity and the79    // nav host stay loosely coupled.80    val newEntryRequest by autofillNav.newEntryRequest.collectAsStateWithLifecycle()81    LaunchedEffect(newEntryRequest, unlocked) {82        val req = newEntryRequest ?: return@LaunchedEffect83        if (!unlocked) return@LaunchedEffect84        autofillNav.consumeNewEntryRequest()85        navController.navigate(86            PeachNav.EntryEdit.of(87                id = null,88                prefillUrl = req.url,89                prefillUsername = req.username,90                prefillEmail = req.email,91            ),92        ) { launchSingleTop = true }93    }94    val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle()95    // Held until the vault is open: the changelog is harmless, but shoving it in96    // front of the lock screen reads as the app having lost its place.97    LaunchedEffect(whatsNewNotes, unlocked) {98        if (whatsNewNotes != null && unlocked &&99            navController.currentDestination?.route != PeachNav.WhatsNew.route100        ) {101            navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }102        }103    }104105    if (isUnlocked != null) {106        LaunchedEffect(unlocked) {107            if (!unlocked) {108                val current = navController.currentDestination?.route109                if (current != null &&110                    current != PeachNav.Lock.route &&111                    current != PeachNav.Onboarding.route112                ) {113                    navController.navigate(PeachNav.Lock.route) {114                        popUpTo(0) { inclusive = true }115                        launchSingleTop = true116                    }117                }118            }119        }120    }121122    Box(Modifier.fillMaxSize()) {123        NavHost(124            navController = navController,125            startDestination = startDestination.route,126            enterTransition = {127                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {128                    slideInVertically(129                        initialOffsetY = { it },130                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),131                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))132                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {133                    EnterTransition.None134                } else {135                    slideInVertically(136                        initialOffsetY = { (it * 0.05f).toInt() },137                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),138                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))139                }140            },141            exitTransition = {142                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {143                    ExitTransition.None144                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {145                    slideOutVertically(146                        targetOffsetY = { it },147                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),148                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))149                } else {150                    slideOutVertically(151                        targetOffsetY = { -(it * 0.03f).toInt() },152                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),153                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))154                }155            },156            popEnterTransition = {157                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {158                    EnterTransition.None159                } else {160                    fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))161                }162            },163            popExitTransition = {164                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {165                    slideOutVertically(166                        targetOffsetY = { it },167                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),168                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))169                } else {170                    slideOutVertically(171                        targetOffsetY = { (it * 0.05f).toInt() },172                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),173                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))174                }175            },176        ) {177        composable(PeachNav.Onboarding.route) {178            OnboardingScreen(179                onFinished = {180                    navController.navigate(PeachNav.Lock.route)181                },182                onRestore = { nav(PeachNav.Import) },183            )184        }185        composable(PeachNav.Lock.route) {186            LockScreen(187                onUnlocked = { dest ->188                    navController.navigate(dest.route) {189                        popUpTo(0) { inclusive = true }190                    }191                },192                onNavigate = nav,193            )194        }195        composable(PeachNav.VaultList.route) {196            // C1: non-null only when the list was pushed on top of an entry197            // (slide-up-to-open from EntryDetailSearchStrip) — slide back198            // down pops back to that exact entry instance.199            val hasOrigin = navController.previousBackStackEntry != null200            VaultListScreen(201                onNavigate = nav,202                onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,203            )204        }205        // No {id}: NotesModeScreen opens on its table of contents, which is the206        // actual notes surface. The notes filter on the vault list is a filter,207        // not this.208        composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back) }209210        composable(211            route = PeachNav.EntryDetail.ROUTE,212            arguments = listOf(navArgument("id") { type = NavType.StringType }),213        ) { backStack ->214            // C1: when this entry is the post-unlock landing surface, its215            // unlock navigation popped Lock off the stack (see the Lock216            // composable below), so there's no previous entry — it's the217            // deck's root, not a screen pushed from the vault list, and218            // shouldn't offer a "back to list" affordance that implies219            // otherwise. Only entries reached by tapping a vault-list row220            // (a real push) get a real back target.221            val isRoot = navController.previousBackStackEntry == null222            EntryDetailScreen(223                id = backStack.arguments?.getString("id").orEmpty(),224                onNavigate = nav,225                onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),226            )227        }228        composable(229            route = PeachNav.EntryEdit.ROUTE,230            arguments = listOf(231                navArgument("id") { type = NavType.StringType; nullable = true },232                navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },233                navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },234                navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },235            ),236        ) { backStack ->237            val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }238            if (editId == null) {239                EntryEditScreen(240                    id = null,241                    onBack = back,242                    onOpenNote = { noteId ->243                        navController.navigate(PeachNav.NotesMode.of(noteId)) {244                            popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }245                        }246                    },247                )248            } else {249                EntryDetailScreen(250                    id = editId,251                    onNavigate = nav,252                    onBack = back,253                )254            }255        }256257        composable(258            route = PeachNav.NotesMode.ROUTE,259            arguments = listOf(navArgument("id") { type = NavType.StringType }),260        ) {261            NotesModeScreen(onBack = back)262        }263        composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }264        composable(PeachNav.CodexGenerate.route) {265            CodexGenerateScreen(266                onBack = back,267                onSetupRecovery = { nav(PeachNav.RecoverySetup) },268                onUpgradePro = { nav(PeachNav.Settings) },269            )270        }271        composable(PeachNav.CodexRestore.route) {272            CodexRestoreScreen(273                onBack = back,274                onRestored = {275                    navController.navigate(PeachNav.VaultList.route) {276                        popUpTo(0) { inclusive = true }277                    }278                },279            )280        }281        composable(PeachNav.CodePair.route) { CodePairScreen(onBack = back) }282        composable(PeachNav.Health.route) {283            VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })284        }285        composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }286        composable(PeachNav.Import.route) {287            ImportScreen(288                onBack = back,289                onCodex = { nav(PeachNav.CodexRestore) },290                onVaultCreated = {291                    navController.navigate(PeachNav.RecoverySetup.route) {292                        popUpTo(0) { inclusive = true }293                    }294                },295            )296        }297        composable(PeachNav.Export.route) { ExportScreen(onBack = back) }298        composable(PeachNav.Panic.route) {299            PanicScreen(300                onBack = back,301                onDestroyed = {302                    navController.navigate(PeachNav.Lock.route) {303                        popUpTo(0) { inclusive = true }304                    }305                },306            )307        }308        composable(PeachNav.RecoverySetup.route) {309            RecoveryWordsScreen(310                requireConfirm = true,311                onDone = {312                    navController.navigate(PeachNav.VaultList.route) {313                        popUpTo(0) { inclusive = true }314                    }315                },316            )317        }318        composable(PeachNav.RecoveryView.route) {319            RecoveryWordsScreen(requireConfirm = false, onDone = back)320        }321        composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }322        composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }323        composable(PeachNav.RecoveryEntry.route) {324            RecoveryWordEntryScreen(325                onSuccess = {326                    navController.navigate(PeachNav.VaultList.route) {327                        popUpTo(0) { inclusive = true }328                    }329                },330                onBack = back,331                onRestore = { nav(PeachNav.Import) },332            )333        }334        }335        CopyFeedbackOverlay()336    }337}338339internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {340    val fromEntry = isEntrySurfaceRoute(fromRoute)341    val toEntry = isEntrySurfaceRoute(toRoute)342    return if (reverse) {343        fromRoute == PeachNav.VaultList.route && toEntry344    } else {345        fromEntry && toRoute == PeachNav.VaultList.route346    }347}348349internal fun isEntrySurfaceRoute(route: String?): Boolean =350    route?.startsWith("entry/") == true || route?.startsWith("notes/") == true