PeachAppNavHost.kt

14.2 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        composable(PeachNav.VaultNotes.route) {184            val hasOrigin = navController.previousBackStackEntry != null185            VaultListScreen(186                onNavigate = nav,187                onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,188                startNoteOnly = true,189            )190        }191192        composable(193            route = PeachNav.EntryDetail.ROUTE,194            arguments = listOf(navArgument("id") { type = NavType.StringType }),195        ) { backStack ->196            // C1: when this entry is the post-unlock landing surface, its197            // unlock navigation popped Lock off the stack (see the Lock198            // composable below), so there's no previous entry — it's the199            // deck's root, not a screen pushed from the vault list, and200            // shouldn't offer a "back to list" affordance that implies201            // otherwise. Only entries reached by tapping a vault-list row202            // (a real push) get a real back target.203            val isRoot = navController.previousBackStackEntry == null204            EntryDetailScreen(205                id = backStack.arguments?.getString("id").orEmpty(),206                onNavigate = nav,207                onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),208            )209        }210        composable(211            route = PeachNav.EntryEdit.ROUTE,212            arguments = listOf(navArgument("id") { type = NavType.StringType; nullable = true }),213        ) { backStack ->214            val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }215            if (editId == null) {216                EntryEditScreen(217                    id = null,218                    onBack = back,219                    onOpenNote = { noteId ->220                        navController.navigate(PeachNav.NotesMode.of(noteId)) {221                            popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }222                        }223                    },224                )225            } else {226                EntryDetailScreen(227                    id = editId,228                    onNavigate = nav,229                    onBack = back,230                )231            }232        }233234        composable(235            route = PeachNav.NotesMode.ROUTE,236            arguments = listOf(navArgument("id") { type = NavType.StringType }),237        ) {238            NotesModeScreen(onBack = back)239        }240        composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }241        composable(PeachNav.CodexGenerate.route) {242            CodexGenerateScreen(243                onBack = back,244                onSetupRecovery = { nav(PeachNav.RecoverySetup) },245                onUpgradePro = { nav(PeachNav.Settings) },246            )247        }248        composable(PeachNav.CodexRestore.route) {249            CodexRestoreScreen(250                onBack = back,251                onRestored = {252                    navController.navigate(PeachNav.VaultList.route) {253                        popUpTo(0) { inclusive = true }254                    }255                },256            )257        }258        composable(PeachNav.CodePair.route) { CodePairScreen(onBack = back) }259        composable(PeachNav.Health.route) {260            VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })261        }262        composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }263        composable(PeachNav.Import.route) {264            ImportScreen(265                onBack = back,266                onCodex = { nav(PeachNav.CodexRestore) },267                onVaultCreated = {268                    navController.navigate(PeachNav.RecoverySetup.route) {269                        popUpTo(0) { inclusive = true }270                    }271                },272            )273        }274        composable(PeachNav.Export.route) { ExportScreen(onBack = back) }275        composable(PeachNav.Panic.route) {276            PanicScreen(277                onBack = back,278                onDestroyed = {279                    navController.navigate(PeachNav.Lock.route) {280                        popUpTo(0) { inclusive = true }281                    }282                },283            )284        }285        composable(PeachNav.RecoverySetup.route) {286            RecoveryWordsScreen(287                requireConfirm = true,288                onDone = {289                    navController.navigate(PeachNav.VaultList.route) {290                        popUpTo(0) { inclusive = true }291                    }292                },293            )294        }295        composable(PeachNav.RecoveryView.route) {296            RecoveryWordsScreen(requireConfirm = false, onDone = back)297        }298        composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }299        composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }300        composable(PeachNav.RecoveryEntry.route) {301            RecoveryWordEntryScreen(302                onSuccess = {303                    navController.navigate(PeachNav.VaultList.route) {304                        popUpTo(0) { inclusive = true }305                    }306                },307                onBack = back,308                onRestore = { nav(PeachNav.Import) },309            )310        }311        }312        CopyFeedbackOverlay()313    }314}315316internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {317    val fromEntry = isEntrySurfaceRoute(fromRoute)318    val toEntry = isEntrySurfaceRoute(toRoute)319    return if (reverse) {320        fromRoute == PeachNav.VaultList.route && toEntry321    } else {322        fromEntry && toRoute == PeachNav.VaultList.route323    }324}325326internal fun isEntrySurfaceRoute(route: String?): Boolean =327    route?.startsWith("entry/") == true || route?.startsWith("notes/") == true