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.hilt.navigation.compose.hiltViewModel17import androidx.lifecycle.compose.collectAsStateWithLifecycle18import androidx.navigation.NavHostController19import androidx.navigation.NavType20import androidx.navigation.compose.NavHost21import androidx.navigation.compose.composable22import androidx.navigation.compose.rememberNavController23import androidx.navigation.navArgument24import com.peachpasswords.autofill.AutofillNavRepository25import com.peachpasswords.autofill.AutofillNavViewModel26import com.peachpasswords.ui.codex.CodexGenerateScreen27import com.peachpasswords.ui.codex.CodexRestoreScreen28import com.peachpasswords.ui.components.CopyFeedbackOverlay29import com.peachpasswords.ui.lock.LockScreen30import com.peachpasswords.ui.onboarding.OnboardingScreen31import com.peachpasswords.ui.pairing.CodePairScreen32import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen33import com.peachpasswords.ui.settings.PanicScreen34import com.peachpasswords.ui.settings.SettingsScreen35import com.peachpasswords.ui.vault.VaultHealthScreen36import com.peachpasswords.ui.theme.PeachMotion37import com.peachpasswords.ui.updates.UpdateScreen38import com.peachpasswords.ui.updates.UpdateViewModel39import com.peachpasswords.ui.updates.WhatsNewScreen40import com.peachpasswords.ui.vault.EntryDetailScreen41import com.peachpasswords.ui.vault.EntryEditScreen42import com.peachpasswords.ui.vault.ExportScreen43import com.peachpasswords.ui.vault.ImportScreen44import com.peachpasswords.ui.vault.NotesModeScreen45import com.peachpasswords.ui.vault.RecoveryWordsScreen46import com.peachpasswords.ui.vault.TrashScreen47import com.peachpasswords.ui.vault.VaultListScreen4849@Composable50fun PeachAppNavHost(51 navController: NavHostController = rememberNavController(),52 startDestination: PeachNav = PeachNav.Lock,53 isUnlocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,54 autofillNav: AutofillNavRepository = hiltViewModel<AutofillNavViewModel>().repo,55) {56 val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }57 val back: () -> Unit = { navController.popBackStack() }5859 60 61 62 63 val unlocked = if (isUnlocked == null) true else isUnlocked.collectAsStateWithLifecycle().value6465 66 67 val updateVm: UpdateViewModel = hiltViewModel()68 val openUpdateRequested by updateVm.openRequested.collectAsStateWithLifecycle()69 LaunchedEffect(openUpdateRequested) {70 if (openUpdateRequested) {71 updateVm.consumeOpenRequest()72 navController.navigate(PeachNav.Update.route) { launchSingleTop = true }73 }74 }7576 77 78 79 80 val newEntryRequest by autofillNav.newEntryRequest.collectAsStateWithLifecycle()81 LaunchedEffect(newEntryRequest, unlocked) {82 val req = newEntryRequest ?: return@LaunchedEffect83 if (!unlocked) return@LaunchedEffect84 autofillNav.consumeNewEntryRequest()85 navController.navigate(86 PeachNav.EntryEdit.of(87 id = null,88 prefillUrl = req.url,89 prefillUsername = req.username,90 prefillEmail = req.email,91 ),92 ) { launchSingleTop = true }93 }94 val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle()95 96 97 LaunchedEffect(whatsNewNotes, unlocked) {98 if (whatsNewNotes != null && unlocked &&99 navController.currentDestination?.route != PeachNav.WhatsNew.route100 ) {101 navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }102 }103 }104105 if (isUnlocked != null) {106 LaunchedEffect(unlocked) {107 if (!unlocked) {108 val current = navController.currentDestination?.route109 if (current != null &&110 current != PeachNav.Lock.route &&111 current != PeachNav.Onboarding.route112 ) {113 navController.navigate(PeachNav.Lock.route) {114 popUpTo(0) { inclusive = true }115 launchSingleTop = true116 }117 }118 }119 }120 }121122 Box(Modifier.fillMaxSize()) {123 NavHost(124 navController = navController,125 startDestination = startDestination.route,126 enterTransition = {127 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {128 slideInVertically(129 initialOffsetY = { it },130 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),131 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))132 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {133 EnterTransition.None134 } else {135 slideInVertically(136 initialOffsetY = { (it * 0.05f).toInt() },137 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),138 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))139 }140 },141 exitTransition = {142 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {143 ExitTransition.None144 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {145 slideOutVertically(146 targetOffsetY = { it },147 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),148 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))149 } else {150 slideOutVertically(151 targetOffsetY = { -(it * 0.03f).toInt() },152 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),153 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))154 }155 },156 popEnterTransition = {157 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {158 EnterTransition.None159 } else {160 fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))161 }162 },163 popExitTransition = {164 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {165 slideOutVertically(166 targetOffsetY = { it },167 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),168 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))169 } else {170 slideOutVertically(171 targetOffsetY = { (it * 0.05f).toInt() },172 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),173 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))174 }175 },176 ) {177 composable(PeachNav.Onboarding.route) {178 OnboardingScreen(179 onFinished = {180 navController.navigate(PeachNav.Lock.route)181 },182 onRestore = { nav(PeachNav.Import) },183 )184 }185 composable(PeachNav.Lock.route) {186 LockScreen(187 onUnlocked = { dest ->188 navController.navigate(dest.route) {189 popUpTo(0) { inclusive = true }190 }191 },192 onNavigate = nav,193 )194 }195 composable(PeachNav.VaultList.route) {196 197 198 199 val hasOrigin = navController.previousBackStackEntry != null200 VaultListScreen(201 onNavigate = nav,202 onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,203 )204 }205 206 207 208 composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back) }209210 composable(211 route = PeachNav.EntryDetail.ROUTE,212 arguments = listOf(navArgument("id") { type = NavType.StringType }),213 ) { backStack ->214 215 216 217 218 219 220 221 val isRoot = navController.previousBackStackEntry == null222 EntryDetailScreen(223 id = backStack.arguments?.getString("id").orEmpty(),224 onNavigate = nav,225 onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),226 )227 }228 composable(229 route = PeachNav.EntryEdit.ROUTE,230 arguments = listOf(231 navArgument("id") { type = NavType.StringType; nullable = true },232 navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },233 navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },234 navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },235 ),236 ) { backStack ->237 val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }238 if (editId == null) {239 EntryEditScreen(240 id = null,241 onBack = back,242 onOpenNote = { noteId ->243 navController.navigate(PeachNav.NotesMode.of(noteId)) {244 popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }245 }246 },247 )248 } else {249 EntryDetailScreen(250 id = editId,251 onNavigate = nav,252 onBack = back,253 )254 }255 }256257 composable(258 route = PeachNav.NotesMode.ROUTE,259 arguments = listOf(navArgument("id") { type = NavType.StringType }),260 ) {261 NotesModeScreen(onBack = back)262 }263 composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }264 composable(PeachNav.CodexGenerate.route) {265 CodexGenerateScreen(266 onBack = back,267 onSetupRecovery = { nav(PeachNav.RecoverySetup) },268 onUpgradePro = { nav(PeachNav.Settings) },269 )270 }271 composable(PeachNav.CodexRestore.route) {272 CodexRestoreScreen(273 onBack = back,274 onRestored = {275 navController.navigate(PeachNav.VaultList.route) {276 popUpTo(0) { inclusive = true }277 }278 },279 )280 }281 composable(PeachNav.CodePair.route) { CodePairScreen(onBack = back) }282 composable(PeachNav.Health.route) {283 VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })284 }285 composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }286 composable(PeachNav.Import.route) {287 ImportScreen(288 onBack = back,289 onCodex = { nav(PeachNav.CodexRestore) },290 onVaultCreated = {291 navController.navigate(PeachNav.RecoverySetup.route) {292 popUpTo(0) { inclusive = true }293 }294 },295 )296 }297 composable(PeachNav.Export.route) { ExportScreen(onBack = back) }298 composable(PeachNav.Panic.route) {299 PanicScreen(300 onBack = back,301 onDestroyed = {302 navController.navigate(PeachNav.Lock.route) {303 popUpTo(0) { inclusive = true }304 }305 },306 )307 }308 composable(PeachNav.RecoverySetup.route) {309 RecoveryWordsScreen(310 requireConfirm = true,311 onDone = {312 navController.navigate(PeachNav.VaultList.route) {313 popUpTo(0) { inclusive = true }314 }315 },316 )317 }318 composable(PeachNav.RecoveryView.route) {319 RecoveryWordsScreen(requireConfirm = false, onDone = back)320 }321 composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }322 composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }323 composable(PeachNav.RecoveryEntry.route) {324 RecoveryWordEntryScreen(325 onSuccess = {326 navController.navigate(PeachNav.VaultList.route) {327 popUpTo(0) { inclusive = true }328 }329 },330 onBack = back,331 onRestore = { nav(PeachNav.Import) },332 )333 }334 }335 CopyFeedbackOverlay()336 }337}338339internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {340 val fromEntry = isEntrySurfaceRoute(fromRoute)341 val toEntry = isEntrySurfaceRoute(toRoute)342 return if (reverse) {343 fromRoute == PeachNav.VaultList.route && toEntry344 } else {345 fromEntry && toRoute == PeachNav.VaultList.route346 }347}348349internal fun isEntrySurfaceRoute(route: String?): Boolean =350 route?.startsWith("entry/") == true || route?.startsWith("notes/") == true