State Management¶
CyclingAssistant uses the MVVM pattern with a structured approach to UI state, events, and navigation.
ViewModel Structure¶
Each ViewModel exposes two flows:
StateFlow<UiState>— persistent UI state, observed by the composableFlow<Navigation>viaChannel— one-time navigation events
internal class FeatureViewModel(
private val useCase: UseCaseType,
private val dispatcherDefault: CoroutineDispatcher,
) : ViewModel() {
private val _uiState = MutableStateFlow<FeatureUiState>(FeatureUiState.Loading)
val uiState: StateFlow<FeatureUiState> = _uiState.asStateFlow()
private val _navigation = Channel<FeatureNavigation>()
val navigation = _navigation.receiveAsFlow()
init { initialize() }
private fun initialize() {
viewModelScope.launch(dispatcherDefault) { loadData() }
}
fun onEvent(event: FeatureUiEvent) {
viewModelScope.launch(dispatcherDefault) {
when (event) {
is FeatureUiEvent.Action -> handleAction()
}
}
}
private inline fun updateContent(
transform: (FeatureUiState.Content) -> FeatureUiState.Content,
) {
val current = _uiState.value
if (current is FeatureUiState.Content) {
_uiState.value = transform(current)
}
}
}
Key Rules¶
initcallsinitialize()which useslaunch(dispatcherDefault)onEventalways wraps handling inlaunch(dispatcherDefault)dispatcherDefaultis injected via DI (DispatchersQualifier.Default), never hardcodedupdateContenthelper enables partial updates within theContentstate
UiState¶
Sealed interface with explicit states — no meaningless defaults:
internal sealed interface FeatureUiState {
data object Loading : FeatureUiState
data class Content(
val data: DataType,
val overlay: Overlay? = null,
) : FeatureUiState
data class Error(val message: String) : FeatureUiState
}
Overlay Pattern¶
Dialogs, toasts, and transient UI are modeled as an Overlay sealed interface within the Content state:
This keeps dialog state tied to the content that triggered it, avoiding separate state holders.
UiEvent¶
Sealed interface with one definition per feature. data object for parameterless events, data class for parameterized:
internal sealed interface FeatureUiEvent {
data object Refresh : FeatureUiEvent
data class SelectItem(val id: String) : FeatureUiEvent
}
Handler methods in the ViewModel are always private.
Navigation¶
Sealed interface emitted via _navigation.send(...) and collected in the Route composable with LaunchedEffect(Unit):
internal sealed interface FeatureNavigation {
data object ToDashboard : FeatureNavigation
data class ToDetail(val id: String) : FeatureNavigation
}
Screen Pattern¶
Composables follow a two-layer pattern:
| Layer | Name | Visibility | Responsibility |
|---|---|---|---|
| Route | <Name>Route |
internal |
Obtains ViewModel via DI, collects state and navigation |
| Content | <Name>Content |
private |
Pure UI, receives state as params |
@Composable
internal fun FeatureRoute(
onNavigateToDashboard: () -> Unit,
viewModel: FeatureViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.navigation.collect { event ->
when (event) {
FeatureNavigation.ToDashboard -> onNavigateToDashboard()
}
}
}
FeatureContent(uiState = uiState, onEvent = viewModel::onEvent)
}
Navigation Wiring¶
Composables are navigation-agnostic. Only AppNavHost knows about NavController. Feature modules expose NavGraphBuilder extension functions with callback parameters: