PeachAppNavHost.kt

14.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.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.ui.codex.CodexGenerateScreen25import com.peachpasswords.ui.codex.CodexRestoreScreen26import com.peachpasswords.ui.components.CopyFeedbackOverlay27import com.peachpasswords.ui.lock.LockScreen28import com.peachpasswords.ui.onboarding.OnboardingScreen29import com.peachpasswords.ui.pairing.CodePairScreen30import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen31import com.peachpasswords.ui.settings.PanicScreen32import com.peachpasswords.ui.settings.SettingsScreen33import com.peachpasswords.ui.vault.VaultHealthScreen34import com.peachpasswords.ui.theme.PeachMotion35import com.peachpasswords.ui.updates.UpdateScreen36import com.peachpasswords.ui.updates.UpdateViewModel37import com.peachpasswords.ui.updates.WhatsNewScreen38import com.peachpasswords.ui.vault.EntryDetailScreen39import com.peachpasswords.ui.vault.EntryEditScreen40import com.peachpasswords.ui.vault.ExportScreen41import com.peachpasswords.ui.vault.ImportScreen42import com.peachpasswords.ui.vault.NotesModeScreen43import com.peachpasswords.ui.vault.RecoveryWordsScreen44import com.peachpasswords.ui.vault.TrashScreen45import com.peachpasswords.ui.vault.VaultListScreen4647@Composable48fun PeachAppNavHost(49    navController: NavHostController = rememberNavController(),50    startDestination: PeachNav = PeachNav.Lock,51    isUnlocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,52) {53    val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }54    val back: () -> Unit = { navController.popBackStack() }5556    // When the vault locks for any reason — manual lock button, idle auto-lock, or57    // background lock (CF-1/CF-2) — drop to the lock screen and clear history so no58    // post-unlock screen lingers behind it. Guarded so it never fires on the lock59    // or onboarding screens (where the vault is legitimately locked).60    val unlocked = if (isUnlocked == null) true else isUnlocked.collectAsStateWithLifecycle().value6162    // Update surfaces, both driven from outside the nav graph: a notification tap63    // (openRequested) and the once-per-version post-update changelog.64    val updateVm: UpdateViewModel = hiltViewModel()65    val openUpdateRequested by updateVm.openRequested.collectAsStateWithLifecycle()66    LaunchedEffect(openUpdateRequested) {67        if (openUpdateRequested) {68            updateVm.consumeOpenRequest()69            navController.navigate(PeachNav.Update.route) { launchSingleTop = true }70        }71    }72    val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle()73    // Held until the vault is open: the changelog is harmless, but shoving it in74    // front of the lock screen reads as the app having lost its place.75    LaunchedEffect(whatsNewNotes, unlocked) {76        if (whatsNewNotes != null && unlocked &&77            navController.currentDestination?.route != PeachNav.WhatsNew.route78        ) {79            navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }80        }81    }8283    if (isUnlocked != null) {84        LaunchedEffect(unlocked) {85            if (!unlocked) {86                val current = navController.currentDestination?.route87                if (current != null &&88                    current != PeachNav.Lock.route &&89                    current != PeachNav.Onboarding.route90                ) {91                    navController.navigate(PeachNav.Lock.route) {92                        popUpTo(0) { inclusive = true }93                        launchSingleTop = true94                    }95                }96            }97        }98    }99100    Box(Modifier.fillMaxSize()) {101        NavHost(102            navController = navController,103            startDestination = startDestination.route,104            enterTransition = {105                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {106                    slideInVertically(107                        initialOffsetY = { it },108                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),109                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))110                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {111                    EnterTransition.None112                } else {113                    slideInVertically(114                        initialOffsetY = { (it * 0.05f).toInt() },115                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),116                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))117                }118            },119            exitTransition = {120                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {121                    ExitTransition.None122                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {123                    slideOutVertically(124                        targetOffsetY = { it },125                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),126                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))127                } else {128                    slideOutVertically(129                        targetOffsetY = { -(it * 0.03f).toInt() },130                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),131                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))132                }133            },134            popEnterTransition = {135                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {136                    EnterTransition.None137                } else {138                    fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))139                }140            },141            popExitTransition = {142                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {143                    slideOutVertically(144                        targetOffsetY = { it },145                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),146                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))147                } else {148                    slideOutVertically(149                        targetOffsetY = { (it * 0.05f).toInt() },150                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),151                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))152                }153            },154        ) {155        composable(PeachNav.Onboarding.route) {156            OnboardingScreen(157                onFinished = {158                    navController.navigate(PeachNav.Lock.route)159                },160                onRestore = { nav(PeachNav.Import) },161            )162        }163        composable(PeachNav.Lock.route) {164            LockScreen(165                onUnlocked = { dest ->166                    navController.navigate(dest.route) {167                        popUpTo(0) { inclusive = true }168                    }169                },170                onNavigate = nav,171            )172        }173        composable(PeachNav.VaultList.route) {174            // C1: non-null only when the list was pushed on top of an entry175            // (slide-up-to-open from EntryDetailSearchStrip) — slide back176            // down pops back to that exact entry instance.177            val hasOrigin = navController.previousBackStackEntry != null178            VaultListScreen(179                onNavigate = nav,180                onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,181            )182        }183        // No {id}: NotesModeScreen opens on its table of contents, which is the184        // actual notes surface. The notes filter on the vault list is a filter,185        // not this.186        composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back) }187188        composable(189            route = PeachNav.EntryDetail.ROUTE,190            arguments = listOf(navArgument("id") { type = NavType.StringType }),191        ) { backStack ->192            // C1: when this entry is the post-unlock landing surface, its193            // unlock navigation popped Lock off the stack (see the Lock194            // composable below), so there's no previous entry — it's the195            // deck's root, not a screen pushed from the vault list, and196            // shouldn't offer a "back to list" affordance that implies197            // otherwise. Only entries reached by tapping a vault-list row198            // (a real push) get a real back target.199            val isRoot = navController.previousBackStackEntry == null200            EntryDetailScreen(201                id = backStack.arguments?.getString("id").orEmpty(),202                onNavigate = nav,203                onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),204            )205        }206        composable(207            route = PeachNav.EntryEdit.ROUTE,208            arguments = listOf(navArgument("id") { type = NavType.StringType; nullable = true }),209        ) { backStack ->210            val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }211            if (editId == null) {212                EntryEditScreen(213                    id = null,214                    onBack = back,215                    onOpenNote = { noteId ->216                        navController.navigate(PeachNav.NotesMode.of(noteId)) {217                            popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }218                        }219                    },220                )221            } else {222                EntryDetailScreen(223                    id = editId,224                    onNavigate = nav,225                    onBack = back,226                )227            }228        }229230        composable(231            route = PeachNav.NotesMode.ROUTE,232            arguments = listOf(navArgument("id") { type = NavType.StringType }),233        ) {234            NotesModeScreen(onBack = back)235        }236        composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }237        composable(PeachNav.CodexGenerate.route) {238            CodexGenerateScreen(239                onBack = back,240                onSetupRecovery = { nav(PeachNav.RecoverySetup) },241                onUpgradePro = { nav(PeachNav.Settings) },242            )243        }244        composable(PeachNav.CodexRestore.route) {245            CodexRestoreScreen(246                onBack = back,247                onRestored = {248                    navController.navigate(PeachNav.VaultList.route) {249                        popUpTo(0) { inclusive = true }250                    }251                },252            )253        }254        composable(PeachNav.CodePair.route) { CodePairScreen(onBack = back) }255        composable(PeachNav.Health.route) {256            VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })257        }258        composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }259        composable(PeachNav.Import.route) {260            ImportScreen(261                onBack = back,262                onCodex = { nav(PeachNav.CodexRestore) },263                onVaultCreated = {264                    navController.navigate(PeachNav.RecoverySetup.route) {265                        popUpTo(0) { inclusive = true }266                    }267                },268            )269        }270        composable(PeachNav.Export.route) { ExportScreen(onBack = back) }271        composable(PeachNav.Panic.route) {272            PanicScreen(273                onBack = back,274                onDestroyed = {275                    navController.navigate(PeachNav.Lock.route) {276                        popUpTo(0) { inclusive = true }277                    }278                },279            )280        }281        composable(PeachNav.RecoverySetup.route) {282            RecoveryWordsScreen(283                requireConfirm = true,284                onDone = {285                    navController.navigate(PeachNav.VaultList.route) {286                        popUpTo(0) { inclusive = true }287                    }288                },289            )290        }291        composable(PeachNav.RecoveryView.route) {292            RecoveryWordsScreen(requireConfirm = false, onDone = back)293        }294        composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }295        composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }296        composable(PeachNav.RecoveryEntry.route) {297            RecoveryWordEntryScreen(298                onSuccess = {299                    navController.navigate(PeachNav.VaultList.route) {300                        popUpTo(0) { inclusive = true }301                    }302                },303                onBack = back,304                onRestore = { nav(PeachNav.Import) },305            )306        }307        }308        CopyFeedbackOverlay()309    }310}311312internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {313    val fromEntry = isEntrySurfaceRoute(fromRoute)314    val toEntry = isEntrySurfaceRoute(toRoute)315    return if (reverse) {316        fromRoute == PeachNav.VaultList.route && toEntry317    } else {318        fromEntry && toRoute == PeachNav.VaultList.route319    }320}321322internal fun isEntrySurfaceRoute(route: String?): Boolean =323    route?.startsWith("entry/") == true || route?.startsWith("notes/") == true