PeachAppNavHost.kt

13.0 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.lifecycle.compose.collectAsStateWithLifecycle17import androidx.navigation.NavHostController18import androidx.navigation.NavType19import androidx.navigation.compose.NavHost20import androidx.navigation.compose.composable21import androidx.navigation.compose.rememberNavController22import androidx.navigation.navArgument23import com.peachpasswords.ui.codex.CodexGenerateScreen24import com.peachpasswords.ui.codex.CodexRestoreScreen25import com.peachpasswords.ui.components.CopyFeedbackOverlay26import com.peachpasswords.ui.lock.LockScreen27import com.peachpasswords.ui.onboarding.OnboardingScreen28import com.peachpasswords.ui.pairing.ChirpPairScreen29import 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.vault.EntryDetailScreen36import com.peachpasswords.ui.vault.EntryEditScreen37import com.peachpasswords.ui.vault.ExportScreen38import com.peachpasswords.ui.vault.ImportScreen39import com.peachpasswords.ui.vault.NotesModeScreen40import com.peachpasswords.ui.vault.RecoveryWordsScreen41import com.peachpasswords.ui.vault.TrashScreen42import com.peachpasswords.ui.vault.VaultListScreen4344@Composable45fun PeachAppNavHost(46    navController: NavHostController = rememberNavController(),47    startDestination: PeachNav = PeachNav.Lock,48    isUnlocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,49) {50    val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }51    val back: () -> Unit = { navController.popBackStack() }5253    // When the vault locks for any reason — manual lock button, idle auto-lock, or54    // background lock (CF-1/CF-2) — drop to the lock screen and clear history so no55    // post-unlock screen lingers behind it. Guarded so it never fires on the lock56    // or onboarding screens (where the vault is legitimately locked).57    if (isUnlocked != null) {58        val unlocked by isUnlocked.collectAsStateWithLifecycle()59        LaunchedEffect(unlocked) {60            if (!unlocked) {61                val current = navController.currentDestination?.route62                if (current != null &&63                    current != PeachNav.Lock.route &&64                    current != PeachNav.Onboarding.route65                ) {66                    navController.navigate(PeachNav.Lock.route) {67                        popUpTo(0) { inclusive = true }68                        launchSingleTop = true69                    }70                }71            }72        }73    }7475    Box(Modifier.fillMaxSize()) {76        NavHost(77            navController = navController,78            startDestination = startDestination.route,79            enterTransition = {80                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {81                    slideInVertically(82                        initialOffsetY = { it },83                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),84                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))85                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {86                    EnterTransition.None87                } else {88                    slideInVertically(89                        initialOffsetY = { (it * 0.05f).toInt() },90                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),91                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))92                }93            },94            exitTransition = {95                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {96                    ExitTransition.None97                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {98                    slideOutVertically(99                        targetOffsetY = { it },100                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),101                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))102                } else {103                    slideOutVertically(104                        targetOffsetY = { -(it * 0.03f).toInt() },105                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),106                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))107                }108            },109            popEnterTransition = {110                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {111                    EnterTransition.None112                } else {113                    fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))114                }115            },116            popExitTransition = {117                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {118                    slideOutVertically(119                        targetOffsetY = { it },120                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),121                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))122                } else {123                    slideOutVertically(124                        targetOffsetY = { (it * 0.05f).toInt() },125                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),126                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))127                }128            },129        ) {130        composable(PeachNav.Onboarding.route) {131            OnboardingScreen(132                onFinished = {133                    navController.navigate(PeachNav.Lock.route)134                },135                onRestore = { nav(PeachNav.Import) },136            )137        }138        composable(PeachNav.Lock.route) {139            LockScreen(140                onUnlocked = { dest ->141                    navController.navigate(dest.route) {142                        popUpTo(0) { inclusive = true }143                    }144                },145                onNavigate = nav,146            )147        }148        composable(PeachNav.VaultList.route) {149            // C1: non-null only when the list was pushed on top of an entry150            // (slide-up-to-open from EntryDetailSearchStrip) — slide back151            // down pops back to that exact entry instance.152            val hasOrigin = navController.previousBackStackEntry != null153            VaultListScreen(154                onNavigate = nav,155                onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,156            )157        }158        composable(PeachNav.VaultNotes.route) {159            val hasOrigin = navController.previousBackStackEntry != null160            VaultListScreen(161                onNavigate = nav,162                onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,163                startNoteOnly = true,164            )165        }166167        composable(168            route = PeachNav.EntryDetail.ROUTE,169            arguments = listOf(navArgument("id") { type = NavType.StringType }),170        ) { backStack ->171            // C1: when this entry is the post-unlock landing surface, its172            // unlock navigation popped Lock off the stack (see the Lock173            // composable below), so there's no previous entry — it's the174            // deck's root, not a screen pushed from the vault list, and175            // shouldn't offer a "back to list" affordance that implies176            // otherwise. Only entries reached by tapping a vault-list row177            // (a real push) get a real back target.178            val isRoot = navController.previousBackStackEntry == null179            EntryDetailScreen(180                id = backStack.arguments?.getString("id").orEmpty(),181                onNavigate = nav,182                onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),183            )184        }185        composable(186            route = PeachNav.EntryEdit.ROUTE,187            arguments = listOf(navArgument("id") { type = NavType.StringType; nullable = true }),188        ) { backStack ->189            val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }190            if (editId == null) {191                EntryEditScreen(192                    id = null,193                    onBack = back,194                    onOpenNote = { noteId ->195                        navController.navigate(PeachNav.NotesMode.of(noteId)) {196                            popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }197                        }198                    },199                )200            } else {201                EntryDetailScreen(202                    id = editId,203                    onNavigate = nav,204                    onBack = back,205                )206            }207        }208209        composable(210            route = PeachNav.NotesMode.ROUTE,211            arguments = listOf(navArgument("id") { type = NavType.StringType }),212        ) {213            NotesModeScreen(onBack = back)214        }215        composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }216        composable(PeachNav.CodexGenerate.route) {217            CodexGenerateScreen(218                onBack = back,219                onSetupRecovery = { nav(PeachNav.RecoverySetup) },220                onUpgradePro = { nav(PeachNav.Settings) },221            )222        }223        composable(PeachNav.CodexRestore.route) {224            CodexRestoreScreen(225                onBack = back,226                onRestored = {227                    navController.navigate(PeachNav.VaultList.route) {228                        popUpTo(0) { inclusive = true }229                    }230                },231            )232        }233        composable(PeachNav.ChirpPair.route) { ChirpPairScreen(onBack = back) }234        composable(PeachNav.CodePair.route) { CodePairScreen(onBack = back) }235        composable(PeachNav.Health.route) {236            VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })237        }238        composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }239        composable(PeachNav.Import.route) {240            ImportScreen(241                onBack = back,242                onCodex = { nav(PeachNav.CodexRestore) },243                onVaultCreated = {244                    navController.navigate(PeachNav.RecoverySetup.route) {245                        popUpTo(0) { inclusive = true }246                    }247                },248            )249        }250        composable(PeachNav.Export.route) { ExportScreen(onBack = back) }251        composable(PeachNav.Panic.route) {252            PanicScreen(253                onBack = back,254                onDestroyed = {255                    navController.navigate(PeachNav.Lock.route) {256                        popUpTo(0) { inclusive = true }257                    }258                },259            )260        }261        composable(PeachNav.RecoverySetup.route) {262            RecoveryWordsScreen(263                requireConfirm = true,264                onDone = {265                    navController.navigate(PeachNav.VaultList.route) {266                        popUpTo(0) { inclusive = true }267                    }268                },269            )270        }271        composable(PeachNav.RecoveryView.route) {272            RecoveryWordsScreen(requireConfirm = false, onDone = back)273        }274        composable(PeachNav.RecoveryEntry.route) {275            RecoveryWordEntryScreen(276                onSuccess = {277                    navController.navigate(PeachNav.VaultList.route) {278                        popUpTo(0) { inclusive = true }279                    }280                },281                onBack = back,282                onRestore = { nav(PeachNav.Import) },283            )284        }285        }286        CopyFeedbackOverlay()287    }288}289290internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {291    val fromEntry = isEntrySurfaceRoute(fromRoute)292    val toEntry = isEntrySurfaceRoute(toRoute)293    return if (reverse) {294        fromRoute == PeachNav.VaultList.route && toEntry295    } else {296        fromEntry && toRoute == PeachNav.VaultList.route297    }298}299300internal fun isEntrySurfaceRoute(route: String?): Boolean =301    route?.startsWith("entry/") == true || route?.startsWith("notes/") == true