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 autofillNav: AutofillNavRepository = hiltViewModel<AutofillNavViewModel>().repo,62) {63 val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }64 val back: () -> Unit = { navController.popBackStack() }6566 67 68 69 70 val unlocked = if (isUnlocked == null) true else isUnlocked.collectAsStateWithLifecycle().value71 val currentBackStackEntry by navController.currentBackStackEntryAsState()72 val currentRoute = currentBackStackEntry?.destination?.route7374 75 76 val updateVm: UpdateViewModel = hiltViewModel()77 val openUpdateRequested by updateVm.openRequested.collectAsStateWithLifecycle()78 LaunchedEffect(openUpdateRequested) {79 if (openUpdateRequested) {80 updateVm.consumeOpenRequest()81 navController.navigate(PeachNav.Update.route) { launchSingleTop = true }82 }83 }8485 86 87 88 89 val newEntryRequest by autofillNav.newEntryRequest.collectAsStateWithLifecycle()90 LaunchedEffect(newEntryRequest, unlocked) {91 val req = newEntryRequest ?: return@LaunchedEffect92 if (!unlocked) return@LaunchedEffect93 autofillNav.consumeNewEntryRequest()94 navController.navigate(95 PeachNav.EntryEdit.of(96 id = null,97 prefillUrl = req.url,98 prefillUsername = req.username,99 prefillEmail = req.email,100 ),101 ) { launchSingleTop = true }102 }103 104 105 106 val pendingAction by PendingActionQueue.pending.collectAsStateWithLifecycle()107 LaunchedEffect(pendingAction, unlocked) {108 val action = pendingAction ?: return@LaunchedEffect109 if (!unlocked) return@LaunchedEffect110 PendingActionQueue.consume()111 val route = when (action) {112 PendingAction.Search -> PeachNav.VaultList.route113 PendingAction.AddEntry -> PeachNav.EntryEdit.of(id = null)114 PendingAction.Pair -> PeachNav.CodePair.route115 116 117 118 119 PendingAction.PasskeyUnlock -> null120 } ?: return@LaunchedEffect121 navController.navigate(route) { launchSingleTop = true }122 }123124 val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle() 125 126 LaunchedEffect(whatsNewNotes, unlocked) {127 if (whatsNewNotes != null && unlocked &&128 navController.currentDestination?.route != PeachNav.WhatsNew.route129 ) {130 navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }131 }132 }133134 if (isUnlocked != null) {135 LaunchedEffect(unlocked, currentRoute) {136 if (!unlocked && requiresUnlockedVault(currentRoute)) {137 navController.navigate(PeachNav.Lock.route) {138 popUpTo(0) { inclusive = true }139 launchSingleTop = true140 }141 }142 }143 }144145 Box(Modifier.fillMaxSize()) {146 NavHost(147 navController = navController,148 startDestination = startDestination.route,149 enterTransition = {150 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {151 slideInVertically(152 initialOffsetY = { it },153 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),154 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))155 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {156 EnterTransition.None157 } else {158 slideInVertically(159 initialOffsetY = { (it * 0.05f).toInt() },160 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),161 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))162 }163 },164 exitTransition = {165 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {166 ExitTransition.None167 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {168 slideOutVertically(169 targetOffsetY = { it },170 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),171 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))172 } else {173 slideOutVertically(174 targetOffsetY = { -(it * 0.03f).toInt() },175 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),176 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))177 }178 },179 popEnterTransition = {180 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {181 EnterTransition.None182 } else {183 fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))184 }185 },186 popExitTransition = {187 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {188 slideOutVertically(189 targetOffsetY = { it },190 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),191 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))192 } else {193 slideOutVertically(194 targetOffsetY = { (it * 0.05f).toInt() },195 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),196 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))197 }198 },199 ) {200 composable(PeachNav.Onboarding.route) {201 OnboardingScreen(202 onFinished = {203 navController.navigate(PeachNav.Lock.route)204 },205 onRestore = { nav(PeachNav.Import) },206 onAdopt = { nav(PeachNav.AdoptVault) },207 )208 }209 composable(PeachNav.Lock.route) {210 LockScreen(211 onUnlocked = { dest ->212 navController.navigate(dest.route) {213 popUpTo(0) { inclusive = true }214 }215 },216 onNavigate = nav,217 )218 }219 composable(PeachNav.VaultList.route) {220 221 222 223 val hasOrigin = navController.previousBackStackEntry != null224 VaultListScreen(225 onNavigate = nav,226 onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,227 )228 }229 230 231 232 composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back) }233234 composable(235 route = PeachNav.EntryDetail.ROUTE,236 arguments = listOf(navArgument("id") { type = NavType.StringType }),237 ) { backStack ->238 239 240 241 242 243 244 245 val isRoot = navController.previousBackStackEntry == null246 EntryDetailScreen(247 id = backStack.arguments?.getString("id").orEmpty(),248 onNavigate = nav,249 onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),250 )251 }252 composable(253 route = PeachNav.EntryEdit.ROUTE,254 arguments = listOf(255 navArgument("id") { type = NavType.StringType; nullable = true },256 navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },257 navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },258 navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },259 ),260 ) { backStack ->261 val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }262 if (editId == null) {263 EntryEditScreen(264 id = null,265 onBack = back,266 onOpenNote = { noteId ->267 navController.navigate(PeachNav.NotesMode.of(noteId)) {268 popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }269 }270 },271 )272 } else {273 EntryDetailScreen(274 id = editId,275 onNavigate = nav,276 onBack = back,277 )278 }279 }280281 composable(282 route = PeachNav.NotesMode.ROUTE,283 arguments = listOf(navArgument("id") { type = NavType.StringType }),284 ) {285 NotesModeScreen(onBack = back)286 }287 composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }288 composable(PeachNav.CodexGenerate.route) {289 CodexGenerateScreen(290 onBack = back,291 onSetupRecovery = { nav(PeachNav.RecoverySetup) },292 onUpgradePro = { nav(PeachNav.Settings) },293 )294 }295 composable(PeachNav.CodexRestore.route) {296 CodexRestoreScreen(297 onBack = back,298 onRestored = {299 navController.navigate(PeachNav.VaultList.route) {300 popUpTo(0) { inclusive = true }301 }302 },303 )304 }305 composable(PeachNav.CodePair.route) {306 CodePairScreen(307 onBack = back,308 onOpenPro = { nav(PeachNav.Settings) },309 )310 }311 composable(PeachNav.AdoptVault.route) {312 val context = LocalContext.current313 AdoptVaultScreen(314 onBack = back,315 onComplete = {316 context317 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)318 .edit()319 .putBoolean(ONBOARD_KEY, true)320 .apply()321 navController.navigate(PeachNav.VaultList.route) {322 popUpTo(0) { inclusive = true }323 }324 },325 )326 }327 composable(PeachNav.Health.route) {328 VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })329 }330 composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }331 composable(PeachNav.Import.route) {332 ImportScreen(333 onBack = back,334 onCodex = { nav(PeachNav.CodexRestore) },335 onVaultCreated = {336 navController.navigate(PeachNav.RecoverySetup.route) {337 popUpTo(0) { inclusive = true }338 }339 },340 )341 }342 composable(PeachNav.Export.route) { ExportScreen(onBack = back) }343 composable(PeachNav.Panic.route) {344 PanicScreen(345 onBack = back,346 onDestroyed = {347 navController.navigate(PeachNav.Lock.route) {348 popUpTo(0) { inclusive = true }349 }350 },351 )352 }353 composable(PeachNav.RecoverySetup.route) {354 RecoveryWordsScreen(355 requireConfirm = true,356 onDone = {357 navController.navigate(PeachNav.VaultList.route) {358 popUpTo(0) { inclusive = true }359 }360 },361 )362 }363 composable(PeachNav.RecoveryView.route) {364 RecoveryWordsScreen(requireConfirm = false, onDone = back)365 }366 composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }367 composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }368 composable(PeachNav.RecoveryEntry.route) {369 RecoveryWordEntryScreen(370 onSuccess = {371 navController.navigate(PeachNav.VaultList.route) {372 popUpTo(0) { inclusive = true }373 }374 },375 onBack = back,376 onRestore = { nav(PeachNav.Import) },377 )378 }379 }380 CopyFeedbackOverlay()381 }382}383384internal fun requiresUnlockedVault(route: String?): Boolean {385 if (route == null) return false386 return route !in setOf(387 PeachNav.Onboarding.route,388 PeachNav.Lock.route,389 PeachNav.AdoptVault.route,390 PeachNav.Import.route,391 PeachNav.CodexRestore.route,392 PeachNav.RecoveryEntry.route,393 PeachNav.Update.route,394 )395}396397internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {398 val fromEntry = isEntrySurfaceRoute(fromRoute)399 val toEntry = isEntrySurfaceRoute(toRoute)400 return if (reverse) {401 fromRoute == PeachNav.VaultList.route && toEntry402 } else {403 fromEntry && toRoute == PeachNav.VaultList.route404 }405}406407internal fun isEntrySurfaceRoute(route: String?): Boolean =408 route?.startsWith("entry/") == true || route?.startsWith("notes/") == true