PeachAppNavHost.kt

12.8 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.CodePairScreen29import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen30import com.peachpasswords.ui.settings.PanicScreen31import com.peachpasswords.ui.settings.SettingsScreen32import com.peachpasswords.ui.vault.VaultHealthScreen33import com.peachpasswords.ui.theme.PeachMotion34import com.peachpasswords.ui.vault.EntryDetailScreen35import com.peachpasswords.ui.vault.EntryEditScreen36import com.peachpasswords.ui.vault.ExportScreen37import com.peachpasswords.ui.vault.ImportScreen38import com.peachpasswords.ui.vault.NotesModeScreen39import com.peachpasswords.ui.vault.RecoveryWordsScreen40import com.peachpasswords.ui.vault.TrashScreen41import com.peachpasswords.ui.vault.VaultListScreen4243@Composable44fun PeachAppNavHost(45    navController: NavHostController = rememberNavController(),46    startDestination: PeachNav = PeachNav.Lock,47    isUnlocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,48) {49    val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }50    val back: () -> Unit = { navController.popBackStack() }5152    // When the vault locks for any reason — manual lock button, idle auto-lock, or53    // background lock (CF-1/CF-2) — drop to the lock screen and clear history so no54    // post-unlock screen lingers behind it. Guarded so it never fires on the lock55    // or onboarding screens (where the vault is legitimately locked).56    if (isUnlocked != null) {57        val unlocked by isUnlocked.collectAsStateWithLifecycle()58        LaunchedEffect(unlocked) {59            if (!unlocked) {60                val current = navController.currentDestination?.route61                if (current != null &&62                    current != PeachNav.Lock.route &&63                    current != PeachNav.Onboarding.route64                ) {65                    navController.navigate(PeachNav.Lock.route) {66                        popUpTo(0) { inclusive = true }67                        launchSingleTop = true68                    }69                }70            }71        }72    }7374    Box(Modifier.fillMaxSize()) {75        NavHost(76            navController = navController,77            startDestination = startDestination.route,78            enterTransition = {79                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {80                    slideInVertically(81                        initialOffsetY = { it },82                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),83                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))84                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {85                    EnterTransition.None86                } else {87                    slideInVertically(88                        initialOffsetY = { (it * 0.05f).toInt() },89                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),90                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))91                }92            },93            exitTransition = {94                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {95                    ExitTransition.None96                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {97                    slideOutVertically(98                        targetOffsetY = { it },99                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),100                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))101                } else {102                    slideOutVertically(103                        targetOffsetY = { -(it * 0.03f).toInt() },104                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),105                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))106                }107            },108            popEnterTransition = {109                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {110                    EnterTransition.None111                } else {112                    fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))113                }114            },115            popExitTransition = {116                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {117                    slideOutVertically(118                        targetOffsetY = { it },119                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),120                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))121                } else {122                    slideOutVertically(123                        targetOffsetY = { (it * 0.05f).toInt() },124                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),125                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))126                }127            },128        ) {129        composable(PeachNav.Onboarding.route) {130            OnboardingScreen(131                onFinished = {132                    navController.navigate(PeachNav.Lock.route)133                },134                onRestore = { nav(PeachNav.Import) },135            )136        }137        composable(PeachNav.Lock.route) {138            LockScreen(139                onUnlocked = { dest ->140                    navController.navigate(dest.route) {141                        popUpTo(0) { inclusive = true }142                    }143                },144                onNavigate = nav,145            )146        }147        composable(PeachNav.VaultList.route) {148            // C1: non-null only when the list was pushed on top of an entry149            // (slide-up-to-open from EntryDetailSearchStrip) — slide back150            // down pops back to that exact entry instance.151            val hasOrigin = navController.previousBackStackEntry != null152            VaultListScreen(153                onNavigate = nav,154                onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,155            )156        }157        composable(PeachNav.VaultNotes.route) {158            val hasOrigin = navController.previousBackStackEntry != null159            VaultListScreen(160                onNavigate = nav,161                onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,162                startNoteOnly = true,163            )164        }165166        composable(167            route = PeachNav.EntryDetail.ROUTE,168            arguments = listOf(navArgument("id") { type = NavType.StringType }),169        ) { backStack ->170            // C1: when this entry is the post-unlock landing surface, its171            // unlock navigation popped Lock off the stack (see the Lock172            // composable below), so there's no previous entry — it's the173            // deck's root, not a screen pushed from the vault list, and174            // shouldn't offer a "back to list" affordance that implies175            // otherwise. Only entries reached by tapping a vault-list row176            // (a real push) get a real back target.177            val isRoot = navController.previousBackStackEntry == null178            EntryDetailScreen(179                id = backStack.arguments?.getString("id").orEmpty(),180                onNavigate = nav,181                onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),182            )183        }184        composable(185            route = PeachNav.EntryEdit.ROUTE,186            arguments = listOf(navArgument("id") { type = NavType.StringType; nullable = true }),187        ) { backStack ->188            val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }189            if (editId == null) {190                EntryEditScreen(191                    id = null,192                    onBack = back,193                    onOpenNote = { noteId ->194                        navController.navigate(PeachNav.NotesMode.of(noteId)) {195                            popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }196                        }197                    },198                )199            } else {200                EntryDetailScreen(201                    id = editId,202                    onNavigate = nav,203                    onBack = back,204                )205            }206        }207208        composable(209            route = PeachNav.NotesMode.ROUTE,210            arguments = listOf(navArgument("id") { type = NavType.StringType }),211        ) {212            NotesModeScreen(onBack = back)213        }214        composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }215        composable(PeachNav.CodexGenerate.route) {216            CodexGenerateScreen(217                onBack = back,218                onSetupRecovery = { nav(PeachNav.RecoverySetup) },219                onUpgradePro = { nav(PeachNav.Settings) },220            )221        }222        composable(PeachNav.CodexRestore.route) {223            CodexRestoreScreen(224                onBack = back,225                onRestored = {226                    navController.navigate(PeachNav.VaultList.route) {227                        popUpTo(0) { inclusive = true }228                    }229                },230            )231        }232        composable(PeachNav.CodePair.route) { CodePairScreen(onBack = back) }233        composable(PeachNav.Health.route) {234            VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })235        }236        composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }237        composable(PeachNav.Import.route) {238            ImportScreen(239                onBack = back,240                onCodex = { nav(PeachNav.CodexRestore) },241                onVaultCreated = {242                    navController.navigate(PeachNav.RecoverySetup.route) {243                        popUpTo(0) { inclusive = true }244                    }245                },246            )247        }248        composable(PeachNav.Export.route) { ExportScreen(onBack = back) }249        composable(PeachNav.Panic.route) {250            PanicScreen(251                onBack = back,252                onDestroyed = {253                    navController.navigate(PeachNav.Lock.route) {254                        popUpTo(0) { inclusive = true }255                    }256                },257            )258        }259        composable(PeachNav.RecoverySetup.route) {260            RecoveryWordsScreen(261                requireConfirm = true,262                onDone = {263                    navController.navigate(PeachNav.VaultList.route) {264                        popUpTo(0) { inclusive = true }265                    }266                },267            )268        }269        composable(PeachNav.RecoveryView.route) {270            RecoveryWordsScreen(requireConfirm = false, onDone = back)271        }272        composable(PeachNav.RecoveryEntry.route) {273            RecoveryWordEntryScreen(274                onSuccess = {275                    navController.navigate(PeachNav.VaultList.route) {276                        popUpTo(0) { inclusive = true }277                    }278                },279                onBack = back,280                onRestore = { nav(PeachNav.Import) },281            )282        }283        }284        CopyFeedbackOverlay()285    }286}287288internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {289    val fromEntry = isEntrySurfaceRoute(fromRoute)290    val toEntry = isEntrySurfaceRoute(toRoute)291    return if (reverse) {292        fromRoute == PeachNav.VaultList.route && toEntry293    } else {294        fromEntry && toRoute == PeachNav.VaultList.route295    }296}297298internal fun isEntrySurfaceRoute(route: String?): Boolean =299    route?.startsWith("entry/") == true || route?.startsWith("notes/") == true