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.PendingAction27import com.peachpasswords.PendingActionQueue28import com.peachpasswords.autofill.AutofillNavRepository29import com.peachpasswords.autofill.AutofillNavViewModel30import com.peachpasswords.ui.codex.CodexGenerateScreen31import com.peachpasswords.ui.codex.CodexRestoreScreen32import com.peachpasswords.ui.components.CopyFeedbackOverlay33import com.peachpasswords.ui.lock.LockScreen34import com.peachpasswords.ui.onboarding.OnboardingScreen35import com.peachpasswords.ui.pairing.CodePairScreen36import com.peachpasswords.ui.pairing.AdoptVaultScreen37import com.peachpasswords.ui.onboarding.ONBOARD_KEY38import com.peachpasswords.ui.onboarding.ONBOARD_PREFS39import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen40import com.peachpasswords.ui.settings.PanicScreen41import com.peachpasswords.ui.settings.SettingsScreen42import com.peachpasswords.ui.vault.VaultHealthScreen43import com.peachpasswords.ui.theme.PeachMotion44import com.peachpasswords.ui.updates.UpdateScreen45import com.peachpasswords.ui.updates.UpdateViewModel46import com.peachpasswords.ui.updates.WhatsNewScreen47import com.peachpasswords.ui.vault.EntryDetailScreen48import com.peachpasswords.ui.vault.EntryEditScreen49import com.peachpasswords.ui.vault.ExportScreen50import com.peachpasswords.ui.vault.ImportScreen51import com.peachpasswords.ui.vault.NotesModeScreen52import com.peachpasswords.ui.vault.RecoveryWordsScreen53import com.peachpasswords.ui.vault.TrashScreen54import com.peachpasswords.ui.vault.VaultListScreen5556@Composable57fun PeachAppNavHost(58 navController: NavHostController = rememberNavController(),59 startDestination: PeachNav = PeachNav.Lock,60 isUnlocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,61 uiGateLocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,62 autofillNav: AutofillNavRepository = hiltViewModel<AutofillNavViewModel>().repo,63) {64 val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }65 val back: () -> Unit = { navController.popBackStack() }6667 68 69 70 71 val uiGated = uiGateLocked?.collectAsStateWithLifecycle()?.value ?: false72 val unlocked = if (isUnlocked == null) true else (isUnlocked.collectAsStateWithLifecycle().value && !uiGated)73 val currentBackStackEntry by navController.currentBackStackEntryAsState()74 val currentRoute = currentBackStackEntry?.destination?.route7576 77 78 val updateVm: UpdateViewModel = hiltViewModel()79 val openUpdateRequested by updateVm.openRequested.collectAsStateWithLifecycle()80 LaunchedEffect(openUpdateRequested) {81 if (openUpdateRequested) {82 updateVm.consumeOpenRequest()83 navController.navigate(PeachNav.Update.route) { launchSingleTop = true }84 }85 }8687 88 89 90 91 val newEntryRequest by autofillNav.newEntryRequest.collectAsStateWithLifecycle()92 LaunchedEffect(newEntryRequest, unlocked) {93 val req = newEntryRequest ?: return@LaunchedEffect94 if (!unlocked) return@LaunchedEffect95 autofillNav.consumeNewEntryRequest()96 97 98 99 navController.navigate(100 PeachNav.EntryEdit.of(101 id = null,102 prefillUrl = req.url,103 prefillUsername = req.username,104 prefillEmail = req.email,105 ),106 ) {107 launchSingleTop = true108 popUpTo(PeachNav.Lock.route) { inclusive = true }109 }110 }111 112 113 114 val pendingAction by PendingActionQueue.pending.collectAsStateWithLifecycle()115 LaunchedEffect(pendingAction, unlocked) {116 val action = pendingAction ?: return@LaunchedEffect117 if (!unlocked) return@LaunchedEffect118 PendingActionQueue.consume()119 val route = when (action) {120 PendingAction.Search -> PeachNav.VaultList.route121 PendingAction.AddEntry -> PeachNav.EntryEdit.of(id = null)122 PendingAction.Pair -> PeachNav.CodePair.route123 124 125 126 127 PendingAction.PasskeyUnlock -> null128 } ?: return@LaunchedEffect129 navController.navigate(route) { launchSingleTop = true }130 }131132 val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle() 133 134 LaunchedEffect(whatsNewNotes, unlocked) {135 if (whatsNewNotes != null && unlocked &&136 navController.currentDestination?.route != PeachNav.WhatsNew.route137 ) {138 navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }139 }140 }141142 if (isUnlocked != null) {143 LaunchedEffect(unlocked, currentRoute) {144 if (!unlocked && requiresUnlockedVault(currentRoute)) {145 navController.navigate(PeachNav.Lock.route) {146 popUpTo(0) { inclusive = true }147 launchSingleTop = true148 }149 }150 }151 }152153 Box(Modifier.fillMaxSize()) {154 NavHost(155 navController = navController,156 startDestination = startDestination.route,157 enterTransition = {158 if (isEntryToEntryTransition(initialState.destination.route, targetState.destination.route)) {159 fadeIn(tween(180))160 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {161 slideInVertically(162 initialOffsetY = { it },163 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),164 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))165 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {166 EnterTransition.None167 } else {168 slideInVertically(169 initialOffsetY = { (it * 0.05f).toInt() },170 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),171 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))172 }173 },174 exitTransition = {175 if (isEntryToEntryTransition(initialState.destination.route, targetState.destination.route)) {176 fadeOut(tween(180))177 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {178 ExitTransition.None179 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {180 slideOutVertically(181 targetOffsetY = { it },182 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),183 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))184 } else {185 slideOutVertically(186 targetOffsetY = { -(it * 0.03f).toInt() },187 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),188 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))189 }190 },191 popEnterTransition = {192 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {193 EnterTransition.None194 } else {195 fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))196 }197 },198 popExitTransition = {199 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {200 slideOutVertically(201 targetOffsetY = { it },202 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),203 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))204 } else {205 slideOutVertically(206 targetOffsetY = { (it * 0.05f).toInt() },207 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),208 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))209 }210 },211 ) {212 composable(PeachNav.Onboarding.route) {213 OnboardingScreen(214 onFinished = {215 navController.navigate(PeachNav.Lock.route)216 },217 onRestore = { nav(PeachNav.Import) },218 onAdopt = { nav(PeachNav.AdoptVault) },219 )220 }221 composable(PeachNav.Lock.route) {222 LockScreen(223 onUnlocked = { dest ->224 navController.navigate(dest.route) {225 popUpTo(0) { inclusive = true }226 }227 },228 onNavigate = nav,229 230 231 autofillPending = newEntryRequest != null,232 )233 }234 composable(PeachNav.VaultList.route) {235 236 237 238 val hasOrigin = navController.previousBackStackEntry != null239 VaultListScreen(240 onNavigate = nav,241 onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,242 )243 }244 composable(PeachNav.VaultSearch.route) {245 VaultListScreen(246 onNavigate = nav,247 onBack = { navController.popBackStack() },248 initialSearchActive = true,249 )250 }251 252 253 254 composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back, onNavigate = nav) }255256 composable(257 route = PeachNav.EntryDetail.ROUTE,258 arguments = listOf(navArgument("id") { type = NavType.StringType }),259 ) { backStack ->260 261 262 263 264 265 266 267 val isRoot = navController.previousBackStackEntry == null268 EntryDetailScreen(269 id = backStack.arguments?.getString("id").orEmpty(),270 onNavigate = nav,271 onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),272 )273 }274 composable(275 route = PeachNav.EntryEdit.ROUTE,276 arguments = listOf(277 navArgument("id") { type = NavType.StringType; nullable = true },278 navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },279 navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },280 navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },281 ),282 ) { backStack ->283 val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }284 if (editId == null) {285 EntryEditScreen(286 id = null,287 onBack = back,288 onHome = { nav(PeachNav.VaultList) },289 )290 } else {291 EntryEditScreen(292 id = editId,293 onBack = back,294 onHome = { nav(PeachNav.VaultList) },295 )296 }297 }298299 composable(300 route = PeachNav.NotesMode.ROUTE,301 arguments = listOf(navArgument("id") { type = NavType.StringType }),302 ) {303 NotesModeScreen(onBack = back, onNavigate = nav)304 }305 composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }306 composable(PeachNav.CodexGenerate.route) {307 CodexGenerateScreen(308 onBack = back,309 onSetupRecovery = { nav(PeachNav.RecoverySetup) },310 onUpgradePro = { nav(PeachNav.Settings) },311 )312 }313 composable(PeachNav.CodexRestore.route) {314 CodexRestoreScreen(315 onBack = back,316 onRestored = {317 navController.navigate(PeachNav.VaultList.route) {318 popUpTo(0) { inclusive = true }319 }320 },321 )322 }323 composable(PeachNav.CodePair.route) {324 CodePairScreen(325 onBack = back,326 onOpenPro = { nav(PeachNav.Settings) },327 )328 }329 composable(PeachNav.AdoptVault.route) {330 val context = LocalContext.current331 AdoptVaultScreen(332 onBack = back,333 onComplete = {334 context335 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)336 .edit()337 .putBoolean(ONBOARD_KEY, true)338 .apply()339 navController.navigate(PeachNav.VaultList.route) {340 popUpTo(0) { inclusive = true }341 }342 },343 )344 }345 composable(PeachNav.Health.route) {346 VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })347 }348 composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }349 composable(PeachNav.Import.route) {350 ImportScreen(351 onBack = back,352 onCodex = { nav(PeachNav.CodexRestore) },353 onVaultCreated = {354 navController.navigate(PeachNav.RecoverySetup.route) {355 popUpTo(0) { inclusive = true }356 }357 },358 )359 }360 composable(PeachNav.Export.route) { ExportScreen(onBack = back) }361 composable(PeachNav.Panic.route) {362 PanicScreen(363 onBack = back,364 onDestroyed = {365 navController.navigate(PeachNav.Lock.route) {366 popUpTo(0) { inclusive = true }367 }368 },369 )370 }371 composable(PeachNav.RecoverySetup.route) {372 RecoveryWordsScreen(373 requireConfirm = true,374 onDone = {375 navController.navigate(PeachNav.VaultList.route) {376 popUpTo(0) { inclusive = true }377 }378 },379 )380 }381 composable(PeachNav.RecoveryView.route) {382 RecoveryWordsScreen(requireConfirm = false, onDone = back)383 }384 composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }385 composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }386 composable(PeachNav.RecoveryEntry.route) {387 RecoveryWordEntryScreen(388 onSuccess = {389 navController.navigate(PeachNav.VaultList.route) {390 popUpTo(0) { inclusive = true }391 }392 },393 onBack = back,394 onRestore = { nav(PeachNav.Import) },395 )396 }397 }398 CopyFeedbackOverlay()399 }400}401402internal fun requiresUnlockedVault(route: String?): Boolean {403 if (route == null) return false404 return route !in setOf(405 PeachNav.Onboarding.route,406 PeachNav.Lock.route,407 PeachNav.AdoptVault.route,408 PeachNav.Import.route,409 PeachNav.CodexRestore.route,410 PeachNav.RecoveryEntry.route,411 PeachNav.Update.route,412 )413}414415internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {416 val fromEntry = isEntrySurfaceRoute(fromRoute)417 val toEntry = isEntrySurfaceRoute(toRoute)418 return if (reverse) {419 fromRoute == PeachNav.VaultList.route && toEntry420 } else {421 fromEntry && toRoute == PeachNav.VaultList.route422 }423}424425internal fun isEntrySurfaceRoute(route: String?): Boolean =426 route?.startsWith("entry/") == true || route?.startsWith("notes/") == true427428internal fun isEntryToEntryTransition(fromRoute: String?, toRoute: String?): Boolean =429 isEntrySurfaceRoute(fromRoute) && isEntrySurfaceRoute(toRoute) && fromRoute != toRoute