PeachAppNavHost.kt

16.7 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.currentBackStackEntryAsState24import androidx.navigation.compose.rememberNavController25import androidx.navigation.navArgument26import com.peachpasswords.autofill.AutofillNavRepository27import com.peachpasswords.autofill.AutofillNavViewModel28import com.peachpasswords.ui.codex.CodexGenerateScreen29import com.peachpasswords.ui.codex.CodexRestoreScreen30import com.peachpasswords.ui.components.CopyFeedbackOverlay31import com.peachpasswords.ui.lock.LockScreen32import com.peachpasswords.ui.onboarding.OnboardingScreen33import com.peachpasswords.ui.pairing.CodePairScreen34import com.peachpasswords.ui.pairing.AdoptVaultScreen35import com.peachpasswords.ui.onboarding.ONBOARD_KEY36import com.peachpasswords.ui.onboarding.ONBOARD_PREFS37import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen38import com.peachpasswords.ui.settings.PanicScreen39import com.peachpasswords.ui.settings.SettingsScreen40import com.peachpasswords.ui.vault.VaultHealthScreen41import com.peachpasswords.ui.theme.PeachMotion42import com.peachpasswords.ui.updates.UpdateScreen43import com.peachpasswords.ui.updates.UpdateViewModel44import com.peachpasswords.ui.updates.WhatsNewScreen45import com.peachpasswords.ui.vault.EntryDetailScreen46import com.peachpasswords.ui.vault.EntryEditScreen47import com.peachpasswords.ui.vault.ExportScreen48import com.peachpasswords.ui.vault.ImportScreen49import com.peachpasswords.ui.vault.NotesModeScreen50import com.peachpasswords.ui.vault.RecoveryWordsScreen51import com.peachpasswords.ui.vault.TrashScreen52import com.peachpasswords.ui.vault.VaultListScreen5354@Composable55fun PeachAppNavHost(56    navController: NavHostController = rememberNavController(),57    startDestination: PeachNav = PeachNav.Lock,58    isUnlocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,59    autofillNav: AutofillNavRepository = hiltViewModel<AutofillNavViewModel>().repo,60) {61    val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }62    val back: () -> Unit = { navController.popBackStack() }6364    // When the vault locks for any reason — manual lock button, idle auto-lock, or65    // background lock (CF-1/CF-2) — drop to the lock screen and clear history so no66    // post-unlock screen lingers behind it. Guarded so it never fires on the lock67    // or onboarding screens (where the vault is legitimately locked).68    val unlocked = if (isUnlocked == null) true else isUnlocked.collectAsStateWithLifecycle().value69    val currentBackStackEntry by navController.currentBackStackEntryAsState()70    val currentRoute = currentBackStackEntry?.destination?.route7172    // Update surfaces, both driven from outside the nav graph: a notification tap73    // (openRequested) and the once-per-version post-update changelog.74    val updateVm: UpdateViewModel = hiltViewModel()75    val openUpdateRequested by updateVm.openRequested.collectAsStateWithLifecycle()76    LaunchedEffect(openUpdateRequested) {77        if (openUpdateRequested) {78            updateVm.consumeOpenRequest()79            navController.navigate(PeachNav.Update.route) { launchSingleTop = true }80        }81    }8283    // "+ Create new entry" deep link from the autofill picker. Holds the request84    // until the vault is open (the user must be unlocked to save a new entry).85    // Mirrors UpdateRepository's request/consume shape so MainActivity and the86    // nav host stay loosely coupled.87    val newEntryRequest by autofillNav.newEntryRequest.collectAsStateWithLifecycle()88    LaunchedEffect(newEntryRequest, unlocked) {89        val req = newEntryRequest ?: return@LaunchedEffect90        if (!unlocked) return@LaunchedEffect91        autofillNav.consumeNewEntryRequest()92        navController.navigate(93            PeachNav.EntryEdit.of(94                id = null,95                prefillUrl = req.url,96                prefillUsername = req.username,97                prefillEmail = req.email,98            ),99        ) { launchSingleTop = true }100    }101    val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle()102    // Held until the vault is open: the changelog is harmless, but shoving it in103    // front of the lock screen reads as the app having lost its place.104    LaunchedEffect(whatsNewNotes, unlocked) {105        if (whatsNewNotes != null && unlocked &&106            navController.currentDestination?.route != PeachNav.WhatsNew.route107        ) {108            navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }109        }110    }111112    if (isUnlocked != null) {113        LaunchedEffect(unlocked, currentRoute) {114            if (!unlocked && requiresUnlockedVault(currentRoute)) {115                navController.navigate(PeachNav.Lock.route) {116                    popUpTo(0) { inclusive = true }117                    launchSingleTop = true118                }119            }120        }121    }122123    Box(Modifier.fillMaxSize()) {124        NavHost(125            navController = navController,126            startDestination = startDestination.route,127            enterTransition = {128                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {129                    slideInVertically(130                        initialOffsetY = { it },131                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),132                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))133                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {134                    EnterTransition.None135                } else {136                    slideInVertically(137                        initialOffsetY = { (it * 0.05f).toInt() },138                        animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),139                    ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))140                }141            },142            exitTransition = {143                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {144                    ExitTransition.None145                } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {146                    slideOutVertically(147                        targetOffsetY = { it },148                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),149                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))150                } else {151                    slideOutVertically(152                        targetOffsetY = { -(it * 0.03f).toInt() },153                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),154                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))155                }156            },157            popEnterTransition = {158                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {159                    EnterTransition.None160                } else {161                    fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))162                }163            },164            popExitTransition = {165                if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {166                    slideOutVertically(167                        targetOffsetY = { it },168                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),169                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))170                } else {171                    slideOutVertically(172                        targetOffsetY = { (it * 0.05f).toInt() },173                        animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),174                    ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))175                }176            },177        ) {178        composable(PeachNav.Onboarding.route) {179            OnboardingScreen(180                onFinished = {181                    navController.navigate(PeachNav.Lock.route)182                },183                onRestore = { nav(PeachNav.Import) },184                onAdopt = { nav(PeachNav.AdoptVault) },185            )186        }187        composable(PeachNav.Lock.route) {188            LockScreen(189                onUnlocked = { dest ->190                    navController.navigate(dest.route) {191                        popUpTo(0) { inclusive = true }192                    }193                },194                onNavigate = nav,195            )196        }197        composable(PeachNav.VaultList.route) {198            // C1: non-null only when the list was pushed on top of an entry199            // (slide-up-to-open from EntryDetailSearchStrip) — slide back200            // down pops back to that exact entry instance.201            val hasOrigin = navController.previousBackStackEntry != null202            VaultListScreen(203                onNavigate = nav,204                onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,205            )206        }207        // No {id}: NotesModeScreen opens on its table of contents, which is the208        // actual notes surface. The notes filter on the vault list is a filter,209        // not this.210        composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back) }211212        composable(213            route = PeachNav.EntryDetail.ROUTE,214            arguments = listOf(navArgument("id") { type = NavType.StringType }),215        ) { backStack ->216            // C1: when this entry is the post-unlock landing surface, its217            // unlock navigation popped Lock off the stack (see the Lock218            // composable below), so there's no previous entry — it's the219            // deck's root, not a screen pushed from the vault list, and220            // shouldn't offer a "back to list" affordance that implies221            // otherwise. Only entries reached by tapping a vault-list row222            // (a real push) get a real back target.223            val isRoot = navController.previousBackStackEntry == null224            EntryDetailScreen(225                id = backStack.arguments?.getString("id").orEmpty(),226                onNavigate = nav,227                onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),228            )229        }230        composable(231            route = PeachNav.EntryEdit.ROUTE,232            arguments = listOf(233                navArgument("id") { type = NavType.StringType; nullable = true },234                navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },235                navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },236                navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },237            ),238        ) { backStack ->239            val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }240            if (editId == null) {241                EntryEditScreen(242                    id = null,243                    onBack = back,244                    onOpenNote = { noteId ->245                        navController.navigate(PeachNav.NotesMode.of(noteId)) {246                            popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }247                        }248                    },249                )250            } else {251                EntryDetailScreen(252                    id = editId,253                    onNavigate = nav,254                    onBack = back,255                )256            }257        }258259        composable(260            route = PeachNav.NotesMode.ROUTE,261            arguments = listOf(navArgument("id") { type = NavType.StringType }),262        ) {263            NotesModeScreen(onBack = back)264        }265        composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }266        composable(PeachNav.CodexGenerate.route) {267            CodexGenerateScreen(268                onBack = back,269                onSetupRecovery = { nav(PeachNav.RecoverySetup) },270                onUpgradePro = { nav(PeachNav.Settings) },271            )272        }273        composable(PeachNav.CodexRestore.route) {274            CodexRestoreScreen(275                onBack = back,276                onRestored = {277                    navController.navigate(PeachNav.VaultList.route) {278                        popUpTo(0) { inclusive = true }279                    }280                },281            )282        }283        composable(PeachNav.CodePair.route) { CodePairScreen(onBack = back) }284        composable(PeachNav.AdoptVault.route) {285            val context = LocalContext.current286            AdoptVaultScreen(287                onBack = back,288                onComplete = {289                    context290                        .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)291                        .edit()292                        .putBoolean(ONBOARD_KEY, true)293                        .apply()294                    navController.navigate(PeachNav.VaultList.route) {295                        popUpTo(0) { inclusive = true }296                    }297                },298            )299        }300        composable(PeachNav.Health.route) {301            VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })302        }303        composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }304        composable(PeachNav.Import.route) {305            ImportScreen(306                onBack = back,307                onCodex = { nav(PeachNav.CodexRestore) },308                onVaultCreated = {309                    navController.navigate(PeachNav.RecoverySetup.route) {310                        popUpTo(0) { inclusive = true }311                    }312                },313            )314        }315        composable(PeachNav.Export.route) { ExportScreen(onBack = back) }316        composable(PeachNav.Panic.route) {317            PanicScreen(318                onBack = back,319                onDestroyed = {320                    navController.navigate(PeachNav.Lock.route) {321                        popUpTo(0) { inclusive = true }322                    }323                },324            )325        }326        composable(PeachNav.RecoverySetup.route) {327            RecoveryWordsScreen(328                requireConfirm = true,329                onDone = {330                    navController.navigate(PeachNav.VaultList.route) {331                        popUpTo(0) { inclusive = true }332                    }333                },334            )335        }336        composable(PeachNav.RecoveryView.route) {337            RecoveryWordsScreen(requireConfirm = false, onDone = back)338        }339        composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }340        composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }341        composable(PeachNav.RecoveryEntry.route) {342            RecoveryWordEntryScreen(343                onSuccess = {344                    navController.navigate(PeachNav.VaultList.route) {345                        popUpTo(0) { inclusive = true }346                    }347                },348                onBack = back,349                onRestore = { nav(PeachNav.Import) },350            )351        }352        }353        CopyFeedbackOverlay()354    }355}356357internal fun requiresUnlockedVault(route: String?): Boolean {358    if (route == null) return false359    return route !in setOf(360        PeachNav.Onboarding.route,361        PeachNav.Lock.route,362        PeachNav.AdoptVault.route,363        PeachNav.Import.route,364        PeachNav.CodexRestore.route,365        PeachNav.RecoveryEntry.route,366        PeachNav.Update.route,367    )368}369370internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {371    val fromEntry = isEntrySurfaceRoute(fromRoute)372    val toEntry = isEntrySurfaceRoute(toRoute)373    return if (reverse) {374        fromRoute == PeachNav.VaultList.route && toEntry375    } else {376        fromEntry && toRoute == PeachNav.VaultList.route377    }378}379380internal fun isEntrySurfaceRoute(route: String?): Boolean =381    route?.startsWith("entry/") == true || route?.startsWith("notes/") == true