PeachAppNavHost.kt

16.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.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.rememberNavController24import androidx.navigation.navArgument25import com.peachpasswords.autofill.AutofillNavRepository26import com.peachpasswords.autofill.AutofillNavViewModel27import com.peachpasswords.ui.codex.CodexGenerateScreen28import com.peachpasswords.ui.codex.CodexRestoreScreen29import com.peachpasswords.ui.components.CopyFeedbackOverlay30import com.peachpasswords.ui.lock.LockScreen31import com.peachpasswords.ui.onboarding.OnboardingScreen32import com.peachpasswords.ui.pairing.CodePairScreen33import com.peachpasswords.ui.pairing.AdoptVaultScreen34import com.peachpasswords.ui.onboarding.ONBOARD_KEY35import com.peachpasswords.ui.onboarding.ONBOARD_PREFS36import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen37import com.peachpasswords.ui.settings.PanicScreen38import com.peachpasswords.ui.settings.SettingsScreen39import com.peachpasswords.ui.vault.VaultHealthScreen40import com.peachpasswords.ui.theme.PeachMotion41import com.peachpasswords.ui.updates.UpdateScreen42import com.peachpasswords.ui.updates.UpdateViewModel43import com.peachpasswords.ui.updates.WhatsNewScreen44import com.peachpasswords.ui.vault.EntryDetailScreen45import com.peachpasswords.ui.vault.EntryEditScreen46import com.peachpasswords.ui.vault.ExportScreen47import com.peachpasswords.ui.vault.ImportScreen48import com.peachpasswords.ui.vault.NotesModeScreen49import com.peachpasswords.ui.vault.RecoveryWordsScreen50import com.peachpasswords.ui.vault.TrashScreen51import com.peachpasswords.ui.vault.VaultListScreen5253@Composable54fun PeachAppNavHost(55    navController: NavHostController = rememberNavController(),56    startDestination: PeachNav = PeachNav.Lock,57    isUnlocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,58    autofillNav: AutofillNavRepository = hiltViewModel<AutofillNavViewModel>().repo,59) {60    val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }61    val back: () -> Unit = { navController.popBackStack() }6263    // When the vault locks for any reason — manual lock button, idle auto-lock, or64    // background lock (CF-1/CF-2) — drop to the lock screen and clear history so no65    // post-unlock screen lingers behind it. Guarded so it never fires on the lock66    // or onboarding screens (where the vault is legitimately locked).67    val unlocked = if (isUnlocked == null) true else isUnlocked.collectAsStateWithLifecycle().value6869    // Update surfaces, both driven from outside the nav graph: a notification tap70    // (openRequested) and the once-per-version post-update changelog.71    val updateVm: UpdateViewModel = hiltViewModel()72    val openUpdateRequested by updateVm.openRequested.collectAsStateWithLifecycle()73    LaunchedEffect(openUpdateRequested) {74        if (openUpdateRequested) {75            updateVm.consumeOpenRequest()76            navController.navigate(PeachNav.Update.route) { launchSingleTop = true }77        }78    }7980    // "+ Create new entry" deep link from the autofill picker. Holds the request81    // until the vault is open (the user must be unlocked to save a new entry).82    // Mirrors UpdateRepository's request/consume shape so MainActivity and the83    // nav host stay loosely coupled.84    val newEntryRequest by autofillNav.newEntryRequest.collectAsStateWithLifecycle()85    LaunchedEffect(newEntryRequest, unlocked) {86        val req = newEntryRequest ?: return@LaunchedEffect87        if (!unlocked) return@LaunchedEffect88        autofillNav.consumeNewEntryRequest()89        navController.navigate(90            PeachNav.EntryEdit.of(91                id = null,92                prefillUrl = req.url,93                prefillUsername = req.username,94                prefillEmail = req.email,95            ),96        ) { launchSingleTop = true }97    }98    val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle()99    // Held until the vault is open: the changelog is harmless, but shoving it in100    // front of the lock screen reads as the app having lost its place.101    LaunchedEffect(whatsNewNotes, unlocked) {102        if (whatsNewNotes != null && unlocked &&103            navController.currentDestination?.route != PeachNav.WhatsNew.route104        ) {105            navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }106        }107    }108109    if (isUnlocked != null) {110        LaunchedEffect(unlocked) {111            if (!unlocked) {112                val current = navController.currentDestination?.route113                if (current != null &&114                    current != PeachNav.Lock.route &&115                    current != PeachNav.Onboarding.route &&116                    current != PeachNav.AdoptVault.route117                ) {118                    navController.navigate(PeachNav.Lock.route) {119                        popUpTo(0) { inclusive = true }120                        launchSingleTop = true121                    }122                }123            }124        }125    }126127    Box(Modifier.fillMaxSize()) {128        NavHost(129            navController = navController,130            startDestination = startDestination.route,131            enterTransition = {132                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {133                    slideInVertically(134                        initialOffsetY = { it },135                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),136                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))137                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {138                    EnterTransition.None139                } else {140                    slideInVertically(141                        initialOffsetY = { (it * 0.05f).toInt() },142                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),143                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))144                }145            },146            exitTransition = {147                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {148                    ExitTransition.None149                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {150                    slideOutVertically(151                        targetOffsetY = { it },152                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),153                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))154                } else {155                    slideOutVertically(156                        targetOffsetY = { -(it * 0.03f).toInt() },157                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),158                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))159                }160            },161            popEnterTransition = {162                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {163                    EnterTransition.None164                } else {165                    fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))166                }167            },168            popExitTransition = {169                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {170                    slideOutVertically(171                        targetOffsetY = { it },172                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),173                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))174                } else {175                    slideOutVertically(176                        targetOffsetY = { (it * 0.05f).toInt() },177                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),178                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))179                }180            },181        ) {182        composable(PeachNav.Onboarding.route) {183            OnboardingScreen(184                onFinished = {185                    navController.navigate(PeachNav.Lock.route)186                },187                onRestore = { nav(PeachNav.Import) },188                onAdopt = { nav(PeachNav.AdoptVault) },189            )190        }191        composable(PeachNav.Lock.route) {192            LockScreen(193                onUnlocked = { dest ->194                    navController.navigate(dest.route) {195                        popUpTo(0) { inclusive = true }196                    }197                },198                onNavigate = nav,199            )200        }201        composable(PeachNav.VaultList.route) {202            // C1: non-null only when the list was pushed on top of an entry203            // (slide-up-to-open from EntryDetailSearchStrip) — slide back204            // down pops back to that exact entry instance.205            val hasOrigin = navController.previousBackStackEntry != null206            VaultListScreen(207                onNavigate = nav,208                onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,209            )210        }211        // No {id}: NotesModeScreen opens on its table of contents, which is the212        // actual notes surface. The notes filter on the vault list is a filter,213        // not this.214        composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back) }215216        composable(217            route = PeachNav.EntryDetail.ROUTE,218            arguments = listOf(navArgument("id") { type = NavType.StringType }),219        ) { backStack ->220            // C1: when this entry is the post-unlock landing surface, its221            // unlock navigation popped Lock off the stack (see the Lock222            // composable below), so there's no previous entry — it's the223            // deck's root, not a screen pushed from the vault list, and224            // shouldn't offer a "back to list" affordance that implies225            // otherwise. Only entries reached by tapping a vault-list row226            // (a real push) get a real back target.227            val isRoot = navController.previousBackStackEntry == null228            EntryDetailScreen(229                id = backStack.arguments?.getString("id").orEmpty(),230                onNavigate = nav,231                onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),232            )233        }234        composable(235            route = PeachNav.EntryEdit.ROUTE,236            arguments = listOf(237                navArgument("id") { type = NavType.StringType; nullable = true },238                navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },239                navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },240                navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },241            ),242        ) { backStack ->243            val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }244            if (editId == null) {245                EntryEditScreen(246                    id = null,247                    onBack = back,248                    onOpenNote = { noteId ->249                        navController.navigate(PeachNav.NotesMode.of(noteId)) {250                            popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }251                        }252                    },253                )254            } else {255                EntryDetailScreen(256                    id = editId,257                    onNavigate = nav,258                    onBack = back,259                )260            }261        }262263        composable(264            route = PeachNav.NotesMode.ROUTE,265            arguments = listOf(navArgument("id") { type = NavType.StringType }),266        ) {267            NotesModeScreen(onBack = back)268        }269        composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }270        composable(PeachNav.CodexGenerate.route) {271            CodexGenerateScreen(272                onBack = back,273                onSetupRecovery = { nav(PeachNav.RecoverySetup) },274                onUpgradePro = { nav(PeachNav.Settings) },275            )276        }277        composable(PeachNav.CodexRestore.route) {278            CodexRestoreScreen(279                onBack = back,280                onRestored = {281                    navController.navigate(PeachNav.VaultList.route) {282                        popUpTo(0) { inclusive = true }283                    }284                },285            )286        }287        composable(PeachNav.CodePair.route) { CodePairScreen(onBack = back) }288        composable(PeachNav.AdoptVault.route) {289            val context = LocalContext.current290            AdoptVaultScreen(291                onBack = back,292                onComplete = {293                    context294                        .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)295                        .edit()296                        .putBoolean(ONBOARD_KEY, true)297                        .apply()298                    navController.navigate(PeachNav.VaultList.route) {299                        popUpTo(0) { inclusive = true }300                    }301                },302            )303        }304        composable(PeachNav.Health.route) {305            VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })306        }307        composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }308        composable(PeachNav.Import.route) {309            ImportScreen(310                onBack = back,311                onCodex = { nav(PeachNav.CodexRestore) },312                onVaultCreated = {313                    navController.navigate(PeachNav.RecoverySetup.route) {314                        popUpTo(0) { inclusive = true }315                    }316                },317            )318        }319        composable(PeachNav.Export.route) { ExportScreen(onBack = back) }320        composable(PeachNav.Panic.route) {321            PanicScreen(322                onBack = back,323                onDestroyed = {324                    navController.navigate(PeachNav.Lock.route) {325                        popUpTo(0) { inclusive = true }326                    }327                },328            )329        }330        composable(PeachNav.RecoverySetup.route) {331            RecoveryWordsScreen(332                requireConfirm = true,333                onDone = {334                    navController.navigate(PeachNav.VaultList.route) {335                        popUpTo(0) { inclusive = true }336                    }337                },338            )339        }340        composable(PeachNav.RecoveryView.route) {341            RecoveryWordsScreen(requireConfirm = false, onDone = back)342        }343        composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }344        composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }345        composable(PeachNav.RecoveryEntry.route) {346            RecoveryWordEntryScreen(347                onSuccess = {348                    navController.navigate(PeachNav.VaultList.route) {349                        popUpTo(0) { inclusive = true }350                    }351                },352                onBack = back,353                onRestore = { nav(PeachNav.Import) },354            )355        }356        }357        CopyFeedbackOverlay()358    }359}360361internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {362    val fromEntry = isEntrySurfaceRoute(fromRoute)363    val toEntry = isEntrySurfaceRoute(toRoute)364    return if (reverse) {365        fromRoute == PeachNav.VaultList.route && toEntry366    } else {367        fromEntry && toRoute == PeachNav.VaultList.route368    }369}370371internal fun isEntrySurfaceRoute(route: String?): Boolean =372    route?.startsWith("entry/") == true || route?.startsWith("notes/") == true