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.autofill.AutofillNavRepository27import com.peachpasswords.autofill.AutofillNavViewModel28import com.peachpasswords.ui.codex.CodexGenerateScreen29import com.peachpasswords.ui.codex.CodexRestoreScreen30import com.peachpasswords.ui.components.CopyFeedbackOverlay31import com.peachpasswords.ui.lock.LockScreen32import com.peachpasswords.ui.onboarding.OnboardingScreen33import com.peachpasswords.ui.pairing.CodePairScreen34import com.peachpasswords.ui.pairing.AdoptVaultScreen35import com.peachpasswords.ui.onboarding.ONBOARD_KEY36import com.peachpasswords.ui.onboarding.ONBOARD_PREFS37import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen38import com.peachpasswords.ui.settings.PanicScreen39import com.peachpasswords.ui.settings.SettingsScreen40import com.peachpasswords.ui.vault.VaultHealthScreen41import com.peachpasswords.ui.theme.PeachMotion42import com.peachpasswords.ui.updates.UpdateScreen43import com.peachpasswords.ui.updates.UpdateViewModel44import com.peachpasswords.ui.updates.WhatsNewScreen45import com.peachpasswords.ui.vault.EntryDetailScreen46import com.peachpasswords.ui.vault.EntryEditScreen47import com.peachpasswords.ui.vault.ExportScreen48import com.peachpasswords.ui.vault.ImportScreen49import com.peachpasswords.ui.vault.NotesModeScreen50import com.peachpasswords.ui.vault.RecoveryWordsScreen51import com.peachpasswords.ui.vault.TrashScreen52import com.peachpasswords.ui.vault.VaultListScreen5354@Composable55fun PeachAppNavHost(56 navController: NavHostController = rememberNavController(),57 startDestination: PeachNav = PeachNav.Lock,58 isUnlocked: kotlinx.coroutines.flow.StateFlow<Boolean>? = null,59 autofillNav: AutofillNavRepository = hiltViewModel<AutofillNavViewModel>().repo,60) {61 val nav: (PeachNav) -> Unit = { navController.navigate(it.route) }62 val back: () -> Unit = { navController.popBackStack() }6364 65 66 67 68 val unlocked = if (isUnlocked == null) true else isUnlocked.collectAsStateWithLifecycle().value69 val currentBackStackEntry by navController.currentBackStackEntryAsState()70 val currentRoute = currentBackStackEntry?.destination?.route7172 73 74 val updateVm: UpdateViewModel = hiltViewModel()75 val openUpdateRequested by updateVm.openRequested.collectAsStateWithLifecycle()76 LaunchedEffect(openUpdateRequested) {77 if (openUpdateRequested) {78 updateVm.consumeOpenRequest()79 navController.navigate(PeachNav.Update.route) { launchSingleTop = true }80 }81 }8283 84 85 86 87 val newEntryRequest by autofillNav.newEntryRequest.collectAsStateWithLifecycle()88 LaunchedEffect(newEntryRequest, unlocked) {89 val req = newEntryRequest ?: return@LaunchedEffect90 if (!unlocked) return@LaunchedEffect91 autofillNav.consumeNewEntryRequest()92 navController.navigate(93 PeachNav.EntryEdit.of(94 id = null,95 prefillUrl = req.url,96 prefillUsername = req.username,97 prefillEmail = req.email,98 ),99 ) { launchSingleTop = true }100 }101 val whatsNewNotes by updateVm.whatsNew.collectAsStateWithLifecycle()102 103 104 LaunchedEffect(whatsNewNotes, unlocked) {105 if (whatsNewNotes != null && unlocked &&106 navController.currentDestination?.route != PeachNav.WhatsNew.route107 ) {108 navController.navigate(PeachNav.WhatsNew.route) { launchSingleTop = true }109 }110 }111112 if (isUnlocked != null) {113 LaunchedEffect(unlocked, currentRoute) {114 if (!unlocked && requiresUnlockedVault(currentRoute)) {115 navController.navigate(PeachNav.Lock.route) {116 popUpTo(0) { inclusive = true }117 launchSingleTop = true118 }119 }120 }121 }122123 Box(Modifier.fillMaxSize()) {124 NavHost(125 navController = navController,126 startDestination = startDestination.route,127 enterTransition = {128 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {129 slideInVertically(130 initialOffsetY = { it },131 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),132 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))133 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {134 EnterTransition.None135 } else {136 slideInVertically(137 initialOffsetY = { (it * 0.05f).toInt() },138 animationSpec = tween(PeachMotion.Enter, easing = PeachMotion.Spring),139 ) + fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))140 }141 },142 exitTransition = {143 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route)) {144 ExitTransition.None145 } else if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {146 slideOutVertically(147 targetOffsetY = { it },148 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),149 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))150 } else {151 slideOutVertically(152 targetOffsetY = { -(it * 0.03f).toInt() },153 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),154 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))155 }156 },157 popEnterTransition = {158 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {159 EnterTransition.None160 } else {161 fadeIn(tween(PeachMotion.Enter, easing = PeachMotion.Spring))162 }163 },164 popExitTransition = {165 if (isVaultSheetTransition(initialState.destination.route, targetState.destination.route, reverse = true)) {166 slideOutVertically(167 targetOffsetY = { it },168 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),169 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))170 } else {171 slideOutVertically(172 targetOffsetY = { (it * 0.05f).toInt() },173 animationSpec = tween(PeachMotion.Exit, easing = PeachMotion.Retreat),174 ) + fadeOut(tween(PeachMotion.Exit, easing = PeachMotion.Retreat))175 }176 },177 ) {178 composable(PeachNav.Onboarding.route) {179 OnboardingScreen(180 onFinished = {181 navController.navigate(PeachNav.Lock.route)182 },183 onRestore = { nav(PeachNav.Import) },184 onAdopt = { nav(PeachNav.AdoptVault) },185 )186 }187 composable(PeachNav.Lock.route) {188 LockScreen(189 onUnlocked = { dest ->190 navController.navigate(dest.route) {191 popUpTo(0) { inclusive = true }192 }193 },194 onNavigate = nav,195 )196 }197 composable(PeachNav.VaultList.route) {198 199 200 201 val hasOrigin = navController.previousBackStackEntry != null202 VaultListScreen(203 onNavigate = nav,204 onBack = if (hasOrigin) ({ navController.popBackStack() }) else null,205 )206 }207 208 209 210 composable(PeachNav.Notes.route) { NotesModeScreen(onBack = back) }211212 composable(213 route = PeachNav.EntryDetail.ROUTE,214 arguments = listOf(navArgument("id") { type = NavType.StringType }),215 ) { backStack ->216 217 218 219 220 221 222 223 val isRoot = navController.previousBackStackEntry == null224 EntryDetailScreen(225 id = backStack.arguments?.getString("id").orEmpty(),226 onNavigate = nav,227 onBack = if (isRoot) null else ({ navController.navigate(PeachNav.VaultList.route) }),228 )229 }230 composable(231 route = PeachNav.EntryEdit.ROUTE,232 arguments = listOf(233 navArgument("id") { type = NavType.StringType; nullable = true },234 navArgument("prefillUrl") { type = NavType.StringType; nullable = true; defaultValue = null },235 navArgument("prefillUsername") { type = NavType.StringType; nullable = true; defaultValue = null },236 navArgument("prefillEmail") { type = NavType.StringType; nullable = true; defaultValue = null },237 ),238 ) { backStack ->239 val editId = backStack.arguments?.getString("id")?.takeIf { it.isNotBlank() }240 if (editId == null) {241 EntryEditScreen(242 id = null,243 onBack = back,244 onOpenNote = { noteId ->245 navController.navigate(PeachNav.NotesMode.of(noteId)) {246 popUpTo(PeachNav.EntryEdit.ROUTE) { inclusive = true }247 }248 },249 )250 } else {251 EntryDetailScreen(252 id = editId,253 onNavigate = nav,254 onBack = back,255 )256 }257 }258259 composable(260 route = PeachNav.NotesMode.ROUTE,261 arguments = listOf(navArgument("id") { type = NavType.StringType }),262 ) {263 NotesModeScreen(onBack = back)264 }265 composable(PeachNav.Settings.route) { SettingsScreen(onBack = back, onNavigate = nav) }266 composable(PeachNav.CodexGenerate.route) {267 CodexGenerateScreen(268 onBack = back,269 onSetupRecovery = { nav(PeachNav.RecoverySetup) },270 onUpgradePro = { nav(PeachNav.Settings) },271 )272 }273 composable(PeachNav.CodexRestore.route) {274 CodexRestoreScreen(275 onBack = back,276 onRestored = {277 navController.navigate(PeachNav.VaultList.route) {278 popUpTo(0) { inclusive = true }279 }280 },281 )282 }283 composable(PeachNav.CodePair.route) {284 CodePairScreen(285 onBack = back,286 onOpenPro = { nav(PeachNav.Settings) },287 )288 }289 composable(PeachNav.AdoptVault.route) {290 val context = LocalContext.current291 AdoptVaultScreen(292 onBack = back,293 onComplete = {294 context295 .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)296 .edit()297 .putBoolean(ONBOARD_KEY, true)298 .apply()299 navController.navigate(PeachNav.VaultList.route) {300 popUpTo(0) { inclusive = true }301 }302 },303 )304 }305 composable(PeachNav.Health.route) {306 VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })307 }308 composable(PeachNav.Trash.route) { TrashScreen(onBack = back) }309 composable(PeachNav.Import.route) {310 ImportScreen(311 onBack = back,312 onCodex = { nav(PeachNav.CodexRestore) },313 onVaultCreated = {314 navController.navigate(PeachNav.RecoverySetup.route) {315 popUpTo(0) { inclusive = true }316 }317 },318 )319 }320 composable(PeachNav.Export.route) { ExportScreen(onBack = back) }321 composable(PeachNav.Panic.route) {322 PanicScreen(323 onBack = back,324 onDestroyed = {325 navController.navigate(PeachNav.Lock.route) {326 popUpTo(0) { inclusive = true }327 }328 },329 )330 }331 composable(PeachNav.RecoverySetup.route) {332 RecoveryWordsScreen(333 requireConfirm = true,334 onDone = {335 navController.navigate(PeachNav.VaultList.route) {336 popUpTo(0) { inclusive = true }337 }338 },339 )340 }341 composable(PeachNav.RecoveryView.route) {342 RecoveryWordsScreen(requireConfirm = false, onDone = back)343 }344 composable(PeachNav.Update.route) { UpdateScreen(onBack = back) }345 composable(PeachNav.WhatsNew.route) { WhatsNewScreen(onDone = back) }346 composable(PeachNav.RecoveryEntry.route) {347 RecoveryWordEntryScreen(348 onSuccess = {349 navController.navigate(PeachNav.VaultList.route) {350 popUpTo(0) { inclusive = true }351 }352 },353 onBack = back,354 onRestore = { nav(PeachNav.Import) },355 )356 }357 }358 CopyFeedbackOverlay()359 }360}361362internal fun requiresUnlockedVault(route: String?): Boolean {363 if (route == null) return false364 return route !in setOf(365 PeachNav.Onboarding.route,366 PeachNav.Lock.route,367 PeachNav.AdoptVault.route,368 PeachNav.Import.route,369 PeachNav.CodexRestore.route,370 PeachNav.RecoveryEntry.route,371 PeachNav.Update.route,372 )373}374375internal fun isVaultSheetTransition(fromRoute: String?, toRoute: String?, reverse: Boolean = false): Boolean {376 val fromEntry = isEntrySurfaceRoute(fromRoute)377 val toEntry = isEntrySurfaceRoute(toRoute)378 return if (reverse) {379 fromRoute == PeachNav.VaultList.route && toEntry380 } else {381 fromEntry && toRoute == PeachNav.VaultList.route382 }383}384385internal fun isEntrySurfaceRoute(route: String?): Boolean =386 route?.startsWith("entry/") == true || route?.startsWith("notes/") == true