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 245 246 247 composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back) }248249 composable(250 route = PeachNav.EntryDetail.ROUTE,251 arguments = listOf(navArgument("id") { type = NavType.StringType }),252 ) { backStack ->253 254 255 256 257 258 259 260 val isRoot = navController.previousBackStackEntry == null261 EntryDetailScreen(262 id = backStack.arguments?.getString("id").orEmpty(),263 onNavigate = nav,264 onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),265 )266 }267 composable(268 route = PeachNav.EntryEdit.ROUTE,269 arguments = listOf(270 navArgument("id") { type = NavType.StringType; nullable = true },271 navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },272 navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },273 navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },274 ),275 ) { backStack ->276 val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }277 if (editId == null) {278 EntryEditScreen(279 id = null,280 onBack = back,281 onOpenNote = { noteId ->282 navController.navigate(PeachNav.NotesMode.of(noteId)) {283 popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }284 }285 },286 )287 } else {288 EntryEditScreen(289 id = editId,290 onBack = back,291 onOpenNote = { noteId -> nav(PeachNav.NotesMode(noteId)) },292 )293 }294 }295296 composable(297 route = PeachNav.NotesMode.ROUTE,298 arguments = listOf(navArgument("id") { type = NavType.StringType }),299 ) {300 NotesModeScreen(onBack = back)301 }302 composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }303 composable(PeachNav.CodexGenerate.route) {304 CodexGenerateScreen(305 onBack = back,306 onSetupRecovery = { nav(PeachNav.RecoverySetup) },307 onUpgradePro = { nav(PeachNav.Settings) },308 )309 }310 composable(PeachNav.CodexRestore.route) {311 CodexRestoreScreen(312 onBack = back,313 onRestored = {314 navController.navigate(PeachNav.VaultList.route) {315 popUpTo(0) { inclusive = true }316 }317 },318 )319 }320 composable(PeachNav.CodePair.route) {321 CodePairScreen(322 onBack = back,323 onOpenPro = { nav(PeachNav.Settings) },324 )325 }326 composable(PeachNav.AdoptVault.route) {327 val context = LocalContext.current328 AdoptVaultScreen(329 onBack = back,330 onComplete = {331 context332 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)333 .edit()334 .putBoolean(ONBOARD_KEY, true)335 .apply()336 navController.navigate(PeachNav.VaultList.route) {337 popUpTo(0) { inclusive = true }338 }339 },340 )341 }342 composable(PeachNav.Health.route) {343 VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })344 }345 composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }346 composable(PeachNav.Import.route) {347 ImportScreen(348 onBack = back,349 onCodex = { nav(PeachNav.CodexRestore) },350 onVaultCreated = {351 navController.navigate(PeachNav.RecoverySetup.route) {352 popUpTo(0) { inclusive = true }353 }354 },355 )356 }357 composable(PeachNav.Export.route) { ExportScreen(onBack = back) }358 composable(PeachNav.Panic.route) {359 PanicScreen(360 onBack = back,361 onDestroyed = {362 navController.navigate(PeachNav.Lock.route) {363 popUpTo(0) { inclusive = true }364 }365 },366 )367 }368 composable(PeachNav.RecoverySetup.route) {369 RecoveryWordsScreen(370 requireConfirm = true,371 onDone = {372 navController.navigate(PeachNav.VaultList.route) {373 popUpTo(0) { inclusive = true }374 }375 },376 )377 }378 composable(PeachNav.RecoveryView.route) {379 RecoveryWordsScreen(requireConfirm = false, onDone = back)380 }381 composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }382 composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }383 composable(PeachNav.RecoveryEntry.route) {384 RecoveryWordEntryScreen(385 onSuccess = {386 navController.navigate(PeachNav.VaultList.route) {387 popUpTo(0) { inclusive = true }388 }389 },390 onBack = back,391 onRestore = { nav(PeachNav.Import) },392 )393 }394 }395 CopyFeedbackOverlay()396 }397}398399internal fun requiresUnlockedVault(route: String?): Boolean {400 if (route == null) return false401 return route !in setOf(402 PeachNav.Onboarding.route,403 PeachNav.Lock.route,404 PeachNav.AdoptVault.route,405 PeachNav.Import.route,406 PeachNav.CodexRestore.route,407 PeachNav.RecoveryEntry.route,408 PeachNav.Update.route,409 )410}411412internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {413 val fromEntry = isEntrySurfaceRoute(fromRoute)414 val toEntry = isEntrySurfaceRoute(toRoute)415 return if (reverse) {416 fromRoute == PeachNav.VaultList.route && toEntry417 } else {418 fromEntry && toRoute == PeachNav.VaultList.route419 }420}421422internal fun isEntrySurfaceRoute(route: String?): Boolean =423 route?.startsWith("entry/") == true || route?.startsWith("notes/") == true424425internal fun isEntryToEntryTransition(fromRoute: String?, toRoute: String?): Boolean =426 isEntrySurfaceRoute(fromRoute) && isEntrySurfaceRoute(toRoute) && fromRoute != toRoute