Coverage Summary for Class: AppNavGraphKt (com.georgeci.moneysurfer.navigation)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| AppNavGraphKt |
16%
(4/25)
|
14.3%
(4/28)
|
42.4%
(56/132)
|
22.5%
(303/1346)
|
| AppNavGraphKt$AppNavGraph$$inlined$entryProvider$default$1 |
0%
(0/1)
|
|
| AppNavGraphKt$AppNavGraph$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/17)
|
| AppNavGraphKt$AppNavGraph$2$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/20)
|
| AppNavGraphKt$AppNavGraph$2$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/3)
|
| AppNavGraphKt$present$1 |
|
| AppNavGraphKt$rememberSnackbarHostState$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/20)
|
| AppNavGraphKt$rememberSnackbarHostState$1$1$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/2)
|
0%
(0/24)
|
| AppNavGraphKt$rememberSnackbarHostState$1$1$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/24)
|
| Total |
12.5%
(4/32)
|
13.3%
(4/30)
|
40.3%
(56/139)
|
20.8%
(303/1454)
|
package com.georgeci.moneysurfer.navigation
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.savedstate.serialization.SavedStateConfiguration
import com.georgeci.moneysurfer.navigation.util.rememberBackNavigationNavEntryDecorator
import com.georgeci.moneysurfer.navigation.util.rememberViewModelStoreNavEntryDecorator
import com.georgeci.moneysurfer.uikit.components.SurferSplash
import com.georgeci.moneysurfer.uikit.theme.AppTheme
import io.github.irgaly.navigation3.resultstate.rememberNavigationResultNavEntryDecorator
import kotlinx.coroutines.launch
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import org.jetbrains.compose.resources.getString
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
/**
* Polymorphic registry for every [Route] that can appear in a saved back stack. Missing an entry
* here silently breaks saved-state restoration for that route (serializer-not-found at restore
* time), so [com.georgeci.moneysurfer.navigation.RouteSerializerRegistryTest] guards it against
* drift from [Route].
*/
internal val navKeySerializersModule = SerializersModule {
polymorphic(NavKey::class) {
subclass(Route.Onboarding::class, Route.Onboarding.serializer())
subclass(Route.SignIn::class, Route.SignIn.serializer())
subclass(Route.Legal::class, Route.Legal.serializer())
subclass(Route.WorkspaceSelector::class, Route.WorkspaceSelector.serializer())
subclass(Route.WorkspaceCreation::class, Route.WorkspaceCreation.serializer())
subclass(Route.WorkspaceMembers::class, Route.WorkspaceMembers.serializer())
subclass(Route.WorkspaceManage::class, Route.WorkspaceManage.serializer())
subclass(Route.WorkspaceInvite::class, Route.WorkspaceInvite.serializer())
subclass(Route.WorkspaceMemberActions::class, Route.WorkspaceMemberActions.serializer())
subclass(Route.IncomingInvites::class, Route.IncomingInvites.serializer())
subclass(Route.Dashboard::class, Route.Dashboard.serializer())
subclass(Route.DashboardCustomize::class, Route.DashboardCustomize.serializer())
subclass(Route.AccountCreation::class, Route.AccountCreation.serializer())
subclass(Route.AccountsManage::class, Route.AccountsManage.serializer())
subclass(Route.CategoryCreation::class, Route.CategoryCreation.serializer())
subclass(Route.CategoriesManage::class, Route.CategoriesManage.serializer())
subclass(Route.CategoryDetails::class, Route.CategoryDetails.serializer())
subclass(Route.CategoryChooser::class, Route.CategoryChooser.serializer())
subclass(Route.Insights::class, Route.Insights.serializer())
subclass(Route.Budgets::class, Route.Budgets.serializer())
subclass(Route.BudgetDetails::class, Route.BudgetDetails.serializer())
subclass(Route.BudgetCreation::class, Route.BudgetCreation.serializer())
subclass(Route.AccountChooser::class, Route.AccountChooser.serializer())
subclass(Route.TransactionsByAccount::class, Route.TransactionsByAccount.serializer())
subclass(Route.TransactionCreation::class, Route.TransactionCreation.serializer())
subclass(
Route.AccountTransactionCreation::class,
Route.AccountTransactionCreation.serializer(),
)
subclass(Route.TransactionFilters::class, Route.TransactionFilters.serializer())
subclass(Route.AccountDetails::class, Route.AccountDetails.serializer())
subclass(Route.TransactionDetails::class, Route.TransactionDetails.serializer())
subclass(Route.Goals::class, Route.Goals.serializer())
subclass(Route.GoalDetails::class, Route.GoalDetails.serializer())
subclass(Route.GoalCreation::class, Route.GoalCreation.serializer())
subclass(Route.GoalEdit::class, Route.GoalEdit.serializer())
subclass(Route.GoalContribution::class, Route.GoalContribution.serializer())
subclass(Route.Settings::class, Route.Settings.serializer())
subclass(Route.SettingsAppearance::class, Route.SettingsAppearance.serializer())
subclass(Route.SettingsPreferences::class, Route.SettingsPreferences.serializer())
subclass(Route.SettingsSync::class, Route.SettingsSync.serializer())
subclass(Route.SettingsBackup::class, Route.SettingsBackup.serializer())
subclass(Route.SettingsCsv::class, Route.SettingsCsv.serializer())
subclass(Route.SettingsAbout::class, Route.SettingsAbout.serializer())
subclass(Route.SettingsLicenses::class, Route.SettingsLicenses.serializer())
subclass(Route.SettingsDeleteAccount::class, Route.SettingsDeleteAccount.serializer())
subclass(Route.SettingsDebugConfig::class, Route.SettingsDebugConfig.serializer())
subclass(Route.SettingsDebugLog::class, Route.SettingsDebugLog.serializer())
}
}
private val savedStateConfig = SavedStateConfiguration {
serializersModule = navKeySerializersModule
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppNavGraph(
featureNavGraphs: List<FeatureNavGraph>,
) {
val backStack = rememberNavBackStack(savedStateConfig, Route.SignIn)
val bottomSheetStrategy = remember { BottomSheetSceneStrategy<NavKey>() }
val paneStrategy = rememberSurferPaneSceneStrategy<NavKey>()
val navigator = remember(backStack) { AppNavigator(backStack) }
val appLaunchViewModel: AppLaunchViewModel = koinViewModel()
val targetRoute by appLaunchViewModel.targetRoute.collectAsStateWithLifecycle()
// The launch decision lands in a `LaunchedEffect`, i.e. one frame *after* the composition that
// first sees a non-null `targetRoute`. Without this flag that frame renders the back stack's
// bootstrap route (`Route.SignIn`) and `NavDisplay` then animates SignIn → Onboarding. It is
// held by the view model, not this composition: a configuration change restores the back stack
// but rebuilds the composition, and a flag that reset there would replay the splash.
val launchRouteApplied by appLaunchViewModel.launchRouteApplied.collectAsStateWithLifecycle()
LaunchedEffect(targetRoute) {
navigator.applyLaunchRoute(targetRoute, backStack.lastOrNull(), appLaunchViewModel::onRouteApplied)
}
// Server-owned flags, pulled here rather than from the view model's `init` because this is the
// only place in the app that has a lifecycle. `repeatOnLifecycle` runs the block on the first
// composition and again on every return to the foreground — which is the whole propagation
// model ADR-004 picked over a persistent snapshot listener. The refresh itself is fire-and-
// forget inside the view model, so nothing here waits on Firestore.
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
appLaunchViewModel.refreshRemoteConfig()
}
}
val entryProvider: (NavKey) -> NavEntry<NavKey> = entryProvider {
featureNavGraphs.forEach { featureNavGraph ->
featureNavGraph(navigator)
}
}
val currentTopLevel by remember(backStack) {
derivedStateOf { backStack.lastOrNull { it is Route.TopLevel } as? Route.TopLevel }
}
val navDisplay: @Composable () -> Unit = {
NavDisplay(
modifier = Modifier.background(AppTheme.materialColors.background),
backStack = backStack,
onBack = { navigator.pop() },
sceneStrategies = listOf(
bottomSheetStrategy,
paneStrategy,
),
transitionSpec = {
slideInHorizontally(tween(ANIMATION_DURATION)) { it } togetherWith
slideOutHorizontally(tween(ANIMATION_DURATION)) { -it }
},
popTransitionSpec = {
slideInHorizontally(tween(ANIMATION_DURATION)) { -it } togetherWith
slideOutHorizontally(tween(ANIMATION_DURATION)) { it }
},
predictivePopTransitionSpec = { progress ->
ContentTransform(
targetContentEnter = slideInHorizontally(tween(ANIMATION_DURATION)) { -it },
initialContentExit = slideOutHorizontally(tween(ANIMATION_DURATION)) { it },
)
},
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
rememberBackNavigationNavEntryDecorator(backStack, entryProvider),
rememberNavigationResultNavEntryDecorator(
backStack = backStack,
entryProvider = entryProvider,
),
),
entryProvider = entryProvider,
)
}
val snackbarHostState = rememberSnackbarHostState(koinInject())
SyncStatusProvider {
Scaffold(
containerColor = AppTheme.materialColors.background,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) { data -> Snackbar(data) } },
) { _ ->
val topLevel = currentTopLevel
if (!launchRouteApplied) {
// Launch decision still pending, or made but not yet on the back stack — hold on
// the splash instead of flashing the bootstrap route (`Route.SignIn`).
SurferSplash()
} else if (topLevel == null) {
navDisplay()
} else {
AppNavigationShell(
currentTopLevel = topLevel,
onSelect = navigator::resetTo,
onOpenWorkspaceSelector = {
navigator.push(Route.WorkspaceSelector(showActions = true))
},
content = navDisplay,
)
}
}
}
}
/**
* Puts a launch decision on the back stack and reports it back through [onApplied], which is what
* stops the same decision from being replayed — the composition that calls this is rebuilt on every
* configuration change while the view model behind [onApplied] is not.
*
* A null [route] is the "no decision yet" state, and also the state right after one was consumed:
* both mean leave the back stack exactly as it is. That second case is the whole point on a
* rotation, where the stack has just been restored and the decision that built it is long spent.
*
* Skipping the reset when [currentTop] already is [route] keeps that destination's entry — and the
* view models scoped to it — alive, instead of tearing it down and rebuilding it for a decision
* that lands where the user already is.
*
* Whole policy outside the composable, taking the two values it needs rather than reading them: the
* navigation host has no test harness in this module, so anything left inside it is untested.
*/
internal fun AppNavigator.applyLaunchRoute(
route: Route?,
currentTop: NavKey?,
onApplied: (Route) -> Unit,
) {
if (route == null) {
return
}
if (currentTop != route) {
resetTo(route)
}
onApplied(route)
}
/** App-level [SnackbarHost] state that renders one-shot messages posted to [controller]. */
@Composable
private fun rememberSnackbarHostState(controller: SnackbarController): SnackbarHostState {
val hostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
LaunchedEffect(controller) {
controller.requests.collect { request ->
hostState.currentSnackbarData?.dismiss()
scope.launch { hostState.present(request) }
}
}
return hostState
}
private suspend fun SnackbarHostState.present(request: SnackbarRequest) {
val actionLabel = request.actionLabel?.let { getString(it) }
val result = showSnackbar(
message = request.resolveMessage(),
actionLabel = actionLabel,
withDismissAction = false,
// Action snackbars (Undo) stay longer so the user can read and reach the button.
duration = if (actionLabel != null) SnackbarDuration.Long else SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed) request.onAction?.invoke()
}
@Suppress("SpreadOperator") // messageArgs holds 0-1 items; the array copy cost is negligible.
private suspend fun SnackbarRequest.resolveMessage(): String =
getString(message, *messageArgs.toTypedArray())
private const val ANIMATION_DURATION = 300