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 (isEntryToEntryTransition(initialState.destination.route, targetState.destination.route)) {153 fadeIn(tween(180))154 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {155 slideInVertically(156 initialOffsetY = { it },157 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),158 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))159 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {160 EnterTransition.None161 } else {162 slideInVertically(163 initialOffsetY = { (it * 0.05f).toInt() },164 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),165 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))166 }167 },168 exitTransition = {169 if (isEntryToEntryTransition(initialState.destination.route, targetState.destination.route)) {170 fadeOut(tween(180))171 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {172 ExitTransition.None173 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {174 slideOutVertically(175 targetOffsetY = { it },176 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),177 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))178 } else {179 slideOutVertically(180 targetOffsetY = { -(it * 0.03f).toInt() },181 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),182 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))183 }184 },185 popEnterTransition = {186 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {187 EnterTransition.None188 } else {189 fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))190 }191 },192 popExitTransition = {193 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {194 slideOutVertically(195 targetOffsetY = { it },196 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),197 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))198 } else {199 slideOutVertically(200 targetOffsetY = { (it * 0.05f).toInt() },201 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),202 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))203 }204 },205 ) {206 composable(PeachNav.Onboarding.route) {207 OnboardingScreen(208 onFinished = {209 navController.navigate(PeachNav.Lock.route)210 },211 onRestore = { nav(PeachNav.Import) },212 onAdopt = { nav(PeachNav.AdoptVault) },213 )214 }215 composable(PeachNav.Lock.route) {216 LockScreen(217 onUnlocked = { dest ->218 navController.navigate(dest.route) {219 popUpTo(0) { inclusive = true }220 }221 },222 onNavigate = nav,223 )224 }225 composable(PeachNav.VaultList.route) {226 227 228 229 val hasOrigin = navController.previousBackStackEntry != null230 VaultListScreen(231 onNavigate = nav,232 onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,233 )234 }235 236 237 238 composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back) }239240 composable(241 route = PeachNav.EntryDetail.ROUTE,242 arguments = listOf(navArgument("id") { type = NavType.StringType }),243 ) { backStack ->244 245 246 247 248 249 250 251 val isRoot = navController.previousBackStackEntry == null252 EntryDetailScreen(253 id = backStack.arguments?.getString("id").orEmpty(),254 onNavigate = nav,255 onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),256 )257 }258 composable(259 route = PeachNav.EntryEdit.ROUTE,260 arguments = listOf(261 navArgument("id") { type = NavType.StringType; nullable = true },262 navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },263 navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },264 navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },265 ),266 ) { backStack ->267 val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }268 if (editId == null) {269 EntryEditScreen(270 id = null,271 onBack = back,272 onOpenNote = { noteId ->273 navController.navigate(PeachNav.NotesMode.of(noteId)) {274 popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }275 }276 },277 )278 } else {279 EntryDetailScreen(280 id = editId,281 onNavigate = nav,282 onBack = back,283 )284 }285 }286287 composable(288 route = PeachNav.NotesMode.ROUTE,289 arguments = listOf(navArgument("id") { type = NavType.StringType }),290 ) {291 NotesModeScreen(onBack = back)292 }293 composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }294 composable(PeachNav.CodexGenerate.route) {295 CodexGenerateScreen(296 onBack = back,297 onSetupRecovery = { nav(PeachNav.RecoverySetup) },298 onUpgradePro = { nav(PeachNav.Settings) },299 )300 }301 composable(PeachNav.CodexRestore.route) {302 CodexRestoreScreen(303 onBack = back,304 onRestored = {305 navController.navigate(PeachNav.VaultList.route) {306 popUpTo(0) { inclusive = true }307 }308 },309 )310 }311 composable(PeachNav.CodePair.route) {312 CodePairScreen(313 onBack = back,314 onOpenPro = { nav(PeachNav.Settings) },315 )316 }317 composable(PeachNav.AdoptVault.route) {318 val context = LocalContext.current319 AdoptVaultScreen(320 onBack = back,321 onComplete = {322 context323 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)324 .edit()325 .putBoolean(ONBOARD_KEY, true)326 .apply()327 navController.navigate(PeachNav.VaultList.route) {328 popUpTo(0) { inclusive = true }329 }330 },331 )332 }333 composable(PeachNav.Health.route) {334 VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })335 }336 composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }337 composable(PeachNav.Import.route) {338 ImportScreen(339 onBack = back,340 onCodex = { nav(PeachNav.CodexRestore) },341 onVaultCreated = {342 navController.navigate(PeachNav.RecoverySetup.route) {343 popUpTo(0) { inclusive = true }344 }345 },346 )347 }348 composable(PeachNav.Export.route) { ExportScreen(onBack = back) }349 composable(PeachNav.Panic.route) {350 PanicScreen(351 onBack = back,352 onDestroyed = {353 navController.navigate(PeachNav.Lock.route) {354 popUpTo(0) { inclusive = true }355 }356 },357 )358 }359 composable(PeachNav.RecoverySetup.route) {360 RecoveryWordsScreen(361 requireConfirm = true,362 onDone = {363 navController.navigate(PeachNav.VaultList.route) {364 popUpTo(0) { inclusive = true }365 }366 },367 )368 }369 composable(PeachNav.RecoveryView.route) {370 RecoveryWordsScreen(requireConfirm = false, onDone = back)371 }372 composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }373 composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }374 composable(PeachNav.RecoveryEntry.route) {375 RecoveryWordEntryScreen(376 onSuccess = {377 navController.navigate(PeachNav.VaultList.route) {378 popUpTo(0) { inclusive = true }379 }380 },381 onBack = back,382 onRestore = { nav(PeachNav.Import) },383 )384 }385 }386 CopyFeedbackOverlay()387 }388}389390internal fun requiresUnlockedVault(route: String?): Boolean {391 if (route == null) return false392 return route !in setOf(393 PeachNav.Onboarding.route,394 PeachNav.Lock.route,395 PeachNav.AdoptVault.route,396 PeachNav.Import.route,397 PeachNav.CodexRestore.route,398 PeachNav.RecoveryEntry.route,399 PeachNav.Update.route,400 )401}402403internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {404 val fromEntry = isEntrySurfaceRoute(fromRoute)405 val toEntry = isEntrySurfaceRoute(toRoute)406 return if (reverse) {407 fromRoute == PeachNav.VaultList.route && toEntry408 } else {409 fromEntry && toRoute == PeachNav.VaultList.route410 }411}412413internal fun isEntrySurfaceRoute(route: String?): Boolean =414 route?.startsWith("entry/") == true || route?.startsWith("notes/") == true415416internal fun isEntryToEntryTransition(fromRoute: String?, toRoute: String?): Boolean =417 isEntrySurfaceRoute(fromRoute) && isEntrySurfaceRoute(toRoute) && fromRoute != toRoute