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 navController.navigate(97 PeachNav.EntryEdit.of(98 id = null,99 prefillUrl = req.url,100 prefillUsername = req.username,101 prefillEmail = req.email,102 ),103 ) { launchSingleTop = true }104 }105 106 107 108 val pendingAction by PendingActionQueue.pending.collectAsStateWithLifecycle()109 LaunchedEffect(pendingAction, unlocked) {110 val action = pendingAction ?: return@LaunchedEffect111 if (!unlocked) return@LaunchedEffect112 PendingActionQueue.consume()113 val route = when (action) {114 PendingAction.Search -> PeachNav.VaultList.route115 PendingAction.AddEntry -> PeachNav.EntryEdit.of(id = null)116 PendingAction.Pair -> PeachNav.CodePair.route117 118 119 120 121 PendingAction.PasskeyUnlock -> null122 } ?: return@LaunchedEffect123 navController.navigate(route) { launchSingleTop = true }124 }125126 val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle() 127 128 LaunchedEffect(whatsNewNotes, unlocked) {129 if (whatsNewNotes != null && unlocked &&130 navController.currentDestination?.route != PeachNav.WhatsNew.route131 ) {132 navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }133 }134 }135136 if (isUnlocked != null) {137 LaunchedEffect(unlocked, currentRoute) {138 if (!unlocked && requiresUnlockedVault(currentRoute)) {139 navController.navigate(PeachNav.Lock.route) {140 popUpTo(0) { inclusive = true }141 launchSingleTop = true142 }143 }144 }145 }146147 Box(Modifier.fillMaxSize()) {148 NavHost(149 navController = navController,150 startDestination = startDestination.route,151 enterTransition = {152 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {153 slideInVertically(154 initialOffsetY = { it },155 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),156 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))157 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {158 EnterTransition.None159 } else {160 slideInVertically(161 initialOffsetY = { (it * 0.05f).toInt() },162 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),163 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))164 }165 },166 exitTransition = {167 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {168 ExitTransition.None169 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {170 slideOutVertically(171 targetOffsetY = { it },172 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),173 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))174 } else {175 slideOutVertically(176 targetOffsetY = { -(it * 0.03f).toInt() },177 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),178 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))179 }180 },181 popEnterTransition = {182 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {183 EnterTransition.None184 } else {185 fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))186 }187 },188 popExitTransition = {189 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {190 slideOutVertically(191 targetOffsetY = { it },192 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),193 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))194 } else {195 slideOutVertically(196 targetOffsetY = { (it * 0.05f).toInt() },197 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),198 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))199 }200 },201 ) {202 composable(PeachNav.Onboarding.route) {203 OnboardingScreen(204 onFinished = {205 navController.navigate(PeachNav.Lock.route)206 },207 onRestore = { nav(PeachNav.Import) },208 onAdopt = { nav(PeachNav.AdoptVault) },209 )210 }211 composable(PeachNav.Lock.route) {212 LockScreen(213 onUnlocked = { dest ->214 navController.navigate(dest.route) {215 popUpTo(0) { inclusive = true }216 }217 },218 onNavigate = nav,219 )220 }221 composable(PeachNav.VaultList.route) {222 223 224 225 val hasOrigin = navController.previousBackStackEntry != null226 VaultListScreen(227 onNavigate = nav,228 onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,229 )230 }231 232 233 234 composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back) }235236 composable(237 route = PeachNav.EntryDetail.ROUTE,238 arguments = listOf(navArgument("id") { type = NavType.StringType }),239 ) { backStack ->240 241 242 243 244 245 246 247 val isRoot = navController.previousBackStackEntry == null248 EntryDetailScreen(249 id = backStack.arguments?.getString("id").orEmpty(),250 onNavigate = nav,251 onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),252 )253 }254 composable(255 route = PeachNav.EntryEdit.ROUTE,256 arguments = listOf(257 navArgument("id") { type = NavType.StringType; nullable = true },258 navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },259 navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },260 navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },261 ),262 ) { backStack ->263 val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }264 if (editId == null) {265 EntryEditScreen(266 id = null,267 onBack = back,268 onOpenNote = { noteId ->269 navController.navigate(PeachNav.NotesMode.of(noteId)) {270 popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }271 }272 },273 )274 } else {275 EntryDetailScreen(276 id = editId,277 onNavigate = nav,278 onBack = back,279 )280 }281 }282283 composable(284 route = PeachNav.NotesMode.ROUTE,285 arguments = listOf(navArgument("id") { type = NavType.StringType }),286 ) {287 NotesModeScreen(onBack = back)288 }289 composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }290 composable(PeachNav.CodexGenerate.route) {291 CodexGenerateScreen(292 onBack = back,293 onSetupRecovery = { nav(PeachNav.RecoverySetup) },294 onUpgradePro = { nav(PeachNav.Settings) },295 )296 }297 composable(PeachNav.CodexRestore.route) {298 CodexRestoreScreen(299 onBack = back,300 onRestored = {301 navController.navigate(PeachNav.VaultList.route) {302 popUpTo(0) { inclusive = true }303 }304 },305 )306 }307 composable(PeachNav.CodePair.route) {308 CodePairScreen(309 onBack = back,310 onOpenPro = { nav(PeachNav.Settings) },311 )312 }313 composable(PeachNav.AdoptVault.route) {314 val context = LocalContext.current315 AdoptVaultScreen(316 onBack = back,317 onComplete = {318 context319 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)320 .edit()321 .putBoolean(ONBOARD_KEY, true)322 .apply()323 navController.navigate(PeachNav.VaultList.route) {324 popUpTo(0) { inclusive = true }325 }326 },327 )328 }329 composable(PeachNav.Health.route) {330 VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })331 }332 composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }333 composable(PeachNav.Import.route) {334 ImportScreen(335 onBack = back,336 onCodex = { nav(PeachNav.CodexRestore) },337 onVaultCreated = {338 navController.navigate(PeachNav.RecoverySetup.route) {339 popUpTo(0) { inclusive = true }340 }341 },342 )343 }344 composable(PeachNav.Export.route) { ExportScreen(onBack = back) }345 composable(PeachNav.Panic.route) {346 PanicScreen(347 onBack = back,348 onDestroyed = {349 navController.navigate(PeachNav.Lock.route) {350 popUpTo(0) { inclusive = true }351 }352 },353 )354 }355 composable(PeachNav.RecoverySetup.route) {356 RecoveryWordsScreen(357 requireConfirm = true,358 onDone = {359 navController.navigate(PeachNav.VaultList.route) {360 popUpTo(0) { inclusive = true }361 }362 },363 )364 }365 composable(PeachNav.RecoveryView.route) {366 RecoveryWordsScreen(requireConfirm = false, onDone = back)367 }368 composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }369 composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }370 composable(PeachNav.RecoveryEntry.route) {371 RecoveryWordEntryScreen(372 onSuccess = {373 navController.navigate(PeachNav.VaultList.route) {374 popUpTo(0) { inclusive = true }375 }376 },377 onBack = back,378 onRestore = { nav(PeachNav.Import) },379 )380 }381 }382 CopyFeedbackOverlay()383 }384}385386internal fun requiresUnlockedVault(route: String?): Boolean {387 if (route == null) return false388 return route !in setOf(389 PeachNav.Onboarding.route,390 PeachNav.Lock.route,391 PeachNav.AdoptVault.route,392 PeachNav.Import.route,393 PeachNav.CodexRestore.route,394 PeachNav.RecoveryEntry.route,395 PeachNav.Update.route,396 )397}398399internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {400 val fromEntry = isEntrySurfaceRoute(fromRoute)401 val toEntry = isEntrySurfaceRoute(toRoute)402 return if (reverse) {403 fromRoute == PeachNav.VaultList.route && toEntry404 } else {405 fromEntry && toRoute == PeachNav.VaultList.route406 }407}408409internal fun isEntrySurfaceRoute(route: String?): Boolean =410 route?.startsWith("entry/") == true || route?.startsWith("notes/") == true