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.rememberNavController24import androidx.navigation.navArgument25import com.peachpasswords.autofill.AutofillNavRepository26import com.peachpasswords.autofill.AutofillNavViewModel27import com.peachpasswords.ui.codex.CodexGenerateScreen28import com.peachpasswords.ui.codex.CodexRestoreScreen29import com.peachpasswords.ui.components.CopyFeedbackOverlay30import com.peachpasswords.ui.lock.LockScreen31import com.peachpasswords.ui.onboarding.OnboardingScreen32import com.peachpasswords.ui.pairing.CodePairScreen33import com.peachpasswords.ui.pairing.AdoptVaultScreen34import com.peachpasswords.ui.onboarding.ONBOARD_KEY35import com.peachpasswords.ui.onboarding.ONBOARD_PREFS36import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen37import com.peachpasswords.ui.settings.PanicScreen38import com.peachpasswords.ui.settings.SettingsScreen39import com.peachpasswords.ui.vault.VaultHealthScreen40import com.peachpasswords.ui.theme.PeachMotion41import com.peachpasswords.ui.updates.UpdateScreen42import com.peachpasswords.ui.updates.UpdateViewModel43import com.peachpasswords.ui.updates.WhatsNewScreen44import com.peachpasswords.ui.vault.EntryDetailScreen45import com.peachpasswords.ui.vault.EntryEditScreen46import com.peachpasswords.ui.vault.ExportScreen47import com.peachpasswords.ui.vault.ImportScreen48import com.peachpasswords.ui.vault.NotesModeScreen49import com.peachpasswords.ui.vault.RecoveryWordsScreen50import com.peachpasswords.ui.vault.TrashScreen51import com.peachpasswords.ui.vault.VaultListScreen5253@Composable54fun PeachAppNavHost(55 navController: NavHostController = rememberNavController(),56 startDestination: PeachNav = PeachNav.Lock,57 isUnlocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,58 autofillNav: AutofillNavRepository = hiltViewModel<AutofillNavViewModel>().repo,59) {60 val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }61 val back: () -> Unit = { navController.popBackStack() }6263 64 65 66 67 val unlocked = if (isUnlocked == null) true else isUnlocked.collectAsStateWithLifecycle().value6869 70 71 val updateVm: UpdateViewModel = hiltViewModel()72 val openUpdateRequested by updateVm.openRequested.collectAsStateWithLifecycle()73 LaunchedEffect(openUpdateRequested) {74 if (openUpdateRequested) {75 updateVm.consumeOpenRequest()76 navController.navigate(PeachNav.Update.route) { launchSingleTop = true }77 }78 }7980 81 82 83 84 val newEntryRequest by autofillNav.newEntryRequest.collectAsStateWithLifecycle()85 LaunchedEffect(newEntryRequest, unlocked) {86 val req = newEntryRequest ?: return@LaunchedEffect87 if (!unlocked) return@LaunchedEffect88 autofillNav.consumeNewEntryRequest()89 navController.navigate(90 PeachNav.EntryEdit.of(91 id = null,92 prefillUrl = req.url,93 prefillUsername = req.username,94 prefillEmail = req.email,95 ),96 ) { launchSingleTop = true }97 }98 val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle()99 100 101 LaunchedEffect(whatsNewNotes, unlocked) {102 if (whatsNewNotes != null && unlocked &&103 navController.currentDestination?.route != PeachNav.WhatsNew.route104 ) {105 navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }106 }107 }108109 if (isUnlocked != null) {110 LaunchedEffect(unlocked) {111 if (!unlocked) {112 val current = navController.currentDestination?.route113 if (current != null &&114 current != PeachNav.Lock.route &&115 current != PeachNav.Onboarding.route &&116 current != PeachNav.AdoptVault.route117 ) {118 navController.navigate(PeachNav.Lock.route) {119 popUpTo(0) { inclusive = true }120 launchSingleTop = true121 }122 }123 }124 }125 }126127 Box(Modifier.fillMaxSize()) {128 NavHost(129 navController = navController,130 startDestination = startDestination.route,131 enterTransition = {132 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {133 slideInVertically(134 initialOffsetY = { it },135 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),136 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))137 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {138 EnterTransition.None139 } else {140 slideInVertically(141 initialOffsetY = { (it * 0.05f).toInt() },142 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),143 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))144 }145 },146 exitTransition = {147 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {148 ExitTransition.None149 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {150 slideOutVertically(151 targetOffsetY = { it },152 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),153 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))154 } else {155 slideOutVertically(156 targetOffsetY = { -(it * 0.03f).toInt() },157 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),158 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))159 }160 },161 popEnterTransition = {162 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {163 EnterTransition.None164 } else {165 fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))166 }167 },168 popExitTransition = {169 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.05f).toInt() },177 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),178 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))179 }180 },181 ) {182 composable(PeachNav.Onboarding.route) {183 OnboardingScreen(184 onFinished = {185 navController.navigate(PeachNav.Lock.route)186 },187 onRestore = { nav(PeachNav.Import) },188 onAdopt = { nav(PeachNav.AdoptVault) },189 )190 }191 composable(PeachNav.Lock.route) {192 LockScreen(193 onUnlocked = { dest ->194 navController.navigate(dest.route) {195 popUpTo(0) { inclusive = true }196 }197 },198 onNavigate = nav,199 )200 }201 composable(PeachNav.VaultList.route) {202 203 204 205 val hasOrigin = navController.previousBackStackEntry != null206 VaultListScreen(207 onNavigate = nav,208 onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,209 )210 }211 212 213 214 composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back) }215216 composable(217 route = PeachNav.EntryDetail.ROUTE,218 arguments = listOf(navArgument("id") { type = NavType.StringType }),219 ) { backStack ->220 221 222 223 224 225 226 227 val isRoot = navController.previousBackStackEntry == null228 EntryDetailScreen(229 id = backStack.arguments?.getString("id").orEmpty(),230 onNavigate = nav,231 onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),232 )233 }234 composable(235 route = PeachNav.EntryEdit.ROUTE,236 arguments = listOf(237 navArgument("id") { type = NavType.StringType; nullable = true },238 navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },239 navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },240 navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },241 ),242 ) { backStack ->243 val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }244 if (editId == null) {245 EntryEditScreen(246 id = null,247 onBack = back,248 onOpenNote = { noteId ->249 navController.navigate(PeachNav.NotesMode.of(noteId)) {250 popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }251 }252 },253 )254 } else {255 EntryDetailScreen(256 id = editId,257 onNavigate = nav,258 onBack = back,259 )260 }261 }262263 composable(264 route = PeachNav.NotesMode.ROUTE,265 arguments = listOf(navArgument("id") { type = NavType.StringType }),266 ) {267 NotesModeScreen(onBack = back)268 }269 composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }270 composable(PeachNav.CodexGenerate.route) {271 CodexGenerateScreen(272 onBack = back,273 onSetupRecovery = { nav(PeachNav.RecoverySetup) },274 onUpgradePro = { nav(PeachNav.Settings) },275 )276 }277 composable(PeachNav.CodexRestore.route) {278 CodexRestoreScreen(279 onBack = back,280 onRestored = {281 navController.navigate(PeachNav.VaultList.route) {282 popUpTo(0) { inclusive = true }283 }284 },285 )286 }287 composable(PeachNav.CodePair.route) { CodePairScreen(onBack = back) }288 composable(PeachNav.AdoptVault.route) {289 val context = LocalContext.current290 AdoptVaultScreen(291 onBack = back,292 onComplete = {293 context294 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)295 .edit()296 .putBoolean(ONBOARD_KEY, true)297 .apply()298 navController.navigate(PeachNav.VaultList.route) {299 popUpTo(0) { inclusive = true }300 }301 },302 )303 }304 composable(PeachNav.Health.route) {305 VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })306 }307 composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }308 composable(PeachNav.Import.route) {309 ImportScreen(310 onBack = back,311 onCodex = { nav(PeachNav.CodexRestore) },312 onVaultCreated = {313 navController.navigate(PeachNav.RecoverySetup.route) {314 popUpTo(0) { inclusive = true }315 }316 },317 )318 }319 composable(PeachNav.Export.route) { ExportScreen(onBack = back) }320 composable(PeachNav.Panic.route) {321 PanicScreen(322 onBack = back,323 onDestroyed = {324 navController.navigate(PeachNav.Lock.route) {325 popUpTo(0) { inclusive = true }326 }327 },328 )329 }330 composable(PeachNav.RecoverySetup.route) {331 RecoveryWordsScreen(332 requireConfirm = true,333 onDone = {334 navController.navigate(PeachNav.VaultList.route) {335 popUpTo(0) { inclusive = true }336 }337 },338 )339 }340 composable(PeachNav.RecoveryView.route) {341 RecoveryWordsScreen(requireConfirm = false, onDone = back)342 }343 composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }344 composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }345 composable(PeachNav.RecoveryEntry.route) {346 RecoveryWordEntryScreen(347 onSuccess = {348 navController.navigate(PeachNav.VaultList.route) {349 popUpTo(0) { inclusive = true }350 }351 },352 onBack = back,353 onRestore = { nav(PeachNav.Import) },354 )355 }356 }357 CopyFeedbackOverlay()358 }359}360361internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {362 val fromEntry = isEntrySurfaceRoute(fromRoute)363 val toEntry = isEntrySurfaceRoute(toRoute)364 return if (reverse) {365 fromRoute == PeachNav.VaultList.route && toEntry366 } else {367 fromEntry && toRoute == PeachNav.VaultList.route368 }369}370371internal fun isEntrySurfaceRoute(route: String?): Boolean =372 route?.startsWith("entry/") == true || route?.startsWith("notes/") == true