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) {130 launchSingleTop = true131 popUpTo(PeachNav.Lock.route) { inclusive = true }132 }133 }134135 val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle() 136 137 LaunchedEffect(whatsNewNotes, unlocked) {138 if (whatsNewNotes != null && unlocked &&139 navController.currentDestination?.route != PeachNav.WhatsNew.route140 ) {141 navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }142 }143 }144145 if (isUnlocked != null) {146 LaunchedEffect(unlocked, currentRoute) {147 if (!unlocked && requiresUnlockedVault(currentRoute)) {148 navController.navigate(PeachNav.Lock.route) {149 popUpTo(0) { inclusive = true }150 launchSingleTop = true151 }152 }153 }154 }155156 Box(Modifier.fillMaxSize()) {157 NavHost(158 navController = navController,159 startDestination = startDestination.route,160 enterTransition = {161 if (isEntryToEntryTransition(initialState.destination.route, targetState.destination.route)) {162 fadeIn(tween(180))163 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {164 slideInVertically(165 initialOffsetY = { it },166 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),167 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))168 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {169 EnterTransition.None170 } else {171 slideInVertically(172 initialOffsetY = { (it * 0.05f).toInt() },173 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),174 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))175 }176 },177 exitTransition = {178 if (isEntryToEntryTransition(initialState.destination.route, targetState.destination.route)) {179 fadeOut(tween(180))180 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {181 ExitTransition.None182 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {183 slideOutVertically(184 targetOffsetY = { it },185 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),186 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))187 } else {188 slideOutVertically(189 targetOffsetY = { -(it * 0.03f).toInt() },190 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),191 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))192 }193 },194 popEnterTransition = {195 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {196 EnterTransition.None197 } else {198 fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))199 }200 },201 popExitTransition = {202 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {203 slideOutVertically(204 targetOffsetY = { it },205 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),206 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))207 } else {208 slideOutVertically(209 targetOffsetY = { (it * 0.05f).toInt() },210 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),211 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))212 }213 },214 ) {215 composable(PeachNav.Onboarding.route) {216 OnboardingScreen(217 onFinished = {218 navController.navigate(PeachNav.Lock.route)219 },220 onRestore = { nav(PeachNav.Import) },221 onAdopt = { nav(PeachNav.AdoptVault) },222 )223 }224 composable(PeachNav.Lock.route) {225 LockScreen(226 onUnlocked = { dest ->227 navController.navigate(dest.route) {228 popUpTo(0) { inclusive = true }229 }230 },231 onNavigate = nav,232 233 234 autofillPending = newEntryRequest != null,235 236 237 238 shortcutPending = pendingAction != null,239 )240 }241 composable(PeachNav.VaultList.route) {242 243 244 245 val hasOrigin = navController.previousBackStackEntry != null246 VaultListScreen(247 onNavigate = nav,248 onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,249 )250 }251 composable(PeachNav.VaultSearch.route) {252 VaultListScreen(253 onNavigate = nav,254 onBack = { navController.popBackStack() },255 initialSearchActive = true,256 )257 }258 259 260 261 composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back, onNavigate = nav) }262263 composable(264 route = PeachNav.EntryDetail.ROUTE,265 arguments = listOf(navArgument("id") { type = NavType.StringType }),266 ) { backStack ->267 268 269 270 271 272 273 274 val isRoot = navController.previousBackStackEntry == null275 EntryDetailScreen(276 id = backStack.arguments?.getString("id").orEmpty(),277 onNavigate = nav,278 onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),279 )280 }281 composable(282 route = PeachNav.EntryEdit.ROUTE,283 arguments = listOf(284 navArgument("id") { type = NavType.StringType; nullable = true },285 navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },286 navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },287 navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },288 ),289 ) { backStack ->290 val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }291 if (editId == null) {292 EntryEditScreen(293 id = null,294 onBack = back,295 onHome = { nav(PeachNav.VaultList) },296 )297 } else {298 EntryEditScreen(299 id = editId,300 onBack = back,301 onHome = { nav(PeachNav.VaultList) },302 )303 }304 }305306 composable(307 route = PeachNav.NotesMode.ROUTE,308 arguments = listOf(navArgument("id") { type = NavType.StringType }),309 ) {310 NotesModeScreen(onBack = back, onNavigate = nav)311 }312 composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }313 composable(PeachNav.CodexGenerate.route) {314 CodexGenerateScreen(315 onBack = back,316 onSetupRecovery = { nav(PeachNav.RecoverySetup) },317 onUpgradePro = { nav(PeachNav.Settings) },318 )319 }320 composable(PeachNav.CodexRestore.route) {321 CodexRestoreScreen(322 onBack = back,323 onRestored = {324 navController.navigate(PeachNav.VaultList.route) {325 popUpTo(0) { inclusive = true }326 }327 },328 )329 }330 composable(PeachNav.CodePair.route) {331 CodePairScreen(332 onBack = back,333 onOpenPro = { nav(PeachNav.Settings) },334 )335 }336 composable(PeachNav.AdoptVault.route) {337 val context = LocalContext.current338 AdoptVaultScreen(339 onBack = back,340 onComplete = {341 context342 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)343 .edit()344 .putBoolean(ONBOARD_KEY, true)345 .apply()346 navController.navigate(PeachNav.VaultList.route) {347 popUpTo(0) { inclusive = true }348 }349 },350 )351 }352 composable(PeachNav.Health.route) {353 VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })354 }355 composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }356 composable(PeachNav.Import.route) {357 ImportScreen(358 onBack = back,359 onCodex = { nav(PeachNav.CodexRestore) },360 onVaultCreated = {361 navController.navigate(PeachNav.RecoverySetup.route) {362 popUpTo(0) { inclusive = true }363 }364 },365 )366 }367 composable(PeachNav.Export.route) { ExportScreen(onBack = back) }368 composable(PeachNav.Panic.route) {369 PanicScreen(370 onBack = back,371 onDestroyed = {372 navController.navigate(PeachNav.Lock.route) {373 popUpTo(0) { inclusive = true }374 }375 },376 )377 }378 composable(PeachNav.RecoverySetup.route) {379 RecoveryWordsScreen(380 requireConfirm = true,381 onDone = {382 navController.navigate(PeachNav.VaultList.route) {383 popUpTo(0) { inclusive = true }384 }385 },386 )387 }388 composable(PeachNav.RecoveryView.route) {389 RecoveryWordsScreen(requireConfirm = false, onDone = back)390 }391 composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }392 composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }393 composable(PeachNav.RecoveryEntry.route) {394 RecoveryWordEntryScreen(395 onSuccess = {396 navController.navigate(PeachNav.VaultList.route) {397 popUpTo(0) { inclusive = true }398 }399 },400 onBack = back,401 onRestore = { nav(PeachNav.Import) },402 )403 }404 }405 CopyFeedbackOverlay()406 }407}408409internal fun requiresUnlockedVault(route: String?): Boolean {410 if (route == null) return false411 return route !in setOf(412 PeachNav.Onboarding.route,413 PeachNav.Lock.route,414 PeachNav.AdoptVault.route,415 PeachNav.Import.route,416 PeachNav.CodexRestore.route,417 PeachNav.RecoveryEntry.route,418 PeachNav.Update.route,419 )420}421422internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {423 val fromEntry = isEntrySurfaceRoute(fromRoute)424 val toEntry = isEntrySurfaceRoute(toRoute)425 return if (reverse) {426 fromRoute == PeachNav.VaultList.route && toEntry427 } else {428 fromEntry && toRoute == PeachNav.VaultList.route429 }430}431432internal fun isEntrySurfaceRoute(route: String?): Boolean =433 route?.startsWith("entry/") == true || route?.startsWith("notes/") == true434435internal fun isEntryToEntryTransition(fromRoute: String?, toRoute: String?): Boolean =436 isEntrySurfaceRoute(fromRoute) && isEntrySurfaceRoute(toRoute) && fromRoute != toRoute