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 onOpenNote = { noteId ->289 navController.navigate(PeachNav.NotesMode.of(noteId)) {290 popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }291 }292 },293 )294 } else {295 EntryEditScreen(296 id = editId,297 onBack = back,298 onOpenNote = { noteId -> nav(PeachNav.NotesMode(noteId)) },299 )300 }301 }302303 composable(304 route = PeachNav.NotesMode.ROUTE,305 arguments = listOf(navArgument("id") { type = NavType.StringType }),306 ) {307 NotesModeScreen(onBack = back, onNavigate = nav)308 }309 composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }310 composable(PeachNav.CodexGenerate.route) {311 CodexGenerateScreen(312 onBack = back,313 onSetupRecovery = { nav(PeachNav.RecoverySetup) },314 onUpgradePro = { nav(PeachNav.Settings) },315 )316 }317 composable(PeachNav.CodexRestore.route) {318 CodexRestoreScreen(319 onBack = back,320 onRestored = {321 navController.navigate(PeachNav.VaultList.route) {322 popUpTo(0) { inclusive = true }323 }324 },325 )326 }327 composable(PeachNav.CodePair.route) {328 CodePairScreen(329 onBack = back,330 onOpenPro = { nav(PeachNav.Settings) },331 )332 }333 composable(PeachNav.AdoptVault.route) {334 val context = LocalContext.current335 AdoptVaultScreen(336 onBack = back,337 onComplete = {338 context339 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)340 .edit()341 .putBoolean(ONBOARD_KEY, true)342 .apply()343 navController.navigate(PeachNav.VaultList.route) {344 popUpTo(0) { inclusive = true }345 }346 },347 )348 }349 composable(PeachNav.Health.route) {350 VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })351 }352 composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }353 composable(PeachNav.Import.route) {354 ImportScreen(355 onBack = back,356 onCodex = { nav(PeachNav.CodexRestore) },357 onVaultCreated = {358 navController.navigate(PeachNav.RecoverySetup.route) {359 popUpTo(0) { inclusive = true }360 }361 },362 )363 }364 composable(PeachNav.Export.route) { ExportScreen(onBack = back) }365 composable(PeachNav.Panic.route) {366 PanicScreen(367 onBack = back,368 onDestroyed = {369 navController.navigate(PeachNav.Lock.route) {370 popUpTo(0) { inclusive = true }371 }372 },373 )374 }375 composable(PeachNav.RecoverySetup.route) {376 RecoveryWordsScreen(377 requireConfirm = true,378 onDone = {379 navController.navigate(PeachNav.VaultList.route) {380 popUpTo(0) { inclusive = true }381 }382 },383 )384 }385 composable(PeachNav.RecoveryView.route) {386 RecoveryWordsScreen(requireConfirm = false, onDone = back)387 }388 composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }389 composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }390 composable(PeachNav.RecoveryEntry.route) {391 RecoveryWordEntryScreen(392 onSuccess = {393 navController.navigate(PeachNav.VaultList.route) {394 popUpTo(0) { inclusive = true }395 }396 },397 onBack = back,398 onRestore = { nav(PeachNav.Import) },399 )400 }401 }402 CopyFeedbackOverlay()403 }404}405406internal fun requiresUnlockedVault(route: String?): Boolean {407 if (route == null) return false408 return route !in setOf(409 PeachNav.Onboarding.route,410 PeachNav.Lock.route,411 PeachNav.AdoptVault.route,412 PeachNav.Import.route,413 PeachNav.CodexRestore.route,414 PeachNav.RecoveryEntry.route,415 PeachNav.Update.route,416 )417}418419internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {420 val fromEntry = isEntrySurfaceRoute(fromRoute)421 val toEntry = isEntrySurfaceRoute(toRoute)422 return if (reverse) {423 fromRoute == PeachNav.VaultList.route && toEntry424 } else {425 fromEntry && toRoute == PeachNav.VaultList.route426 }427}428429internal fun isEntrySurfaceRoute(route: String?): Boolean =430 route?.startsWith("entry/") == true || route?.startsWith("notes/") == true431432internal fun isEntryToEntryTransition(fromRoute: String?, toRoute: String?): Boolean =433 isEntrySurfaceRoute(fromRoute) && isEntrySurfaceRoute(toRoute) && fromRoute != toRoute