MVI Pattern¶
Kite uses Orbit MVI for all feature screen state management.
Core Concepts¶
| Concept | Description |
|---|---|
| State | Immutable snapshot of what the UI currently shows |
| Intent | User action or system event that may change state |
| SideEffect | One-shot event (navigate, show snackbar) — not stored in state |
The ViewModel implements ContainerHost<State, SideEffect> and processes intents via the intent { } block.
Feature Module Structure¶
Every feature follows the same layout:
presentation-feature-{name}/
source/{name}/
{Name}Screen.kt — Navigation entry point, collectAsState / collectSideEffect
{Name}Content.kt — Stateless @Composable, receives State + callbacks
{Name}ViewModel.kt — ContainerHost<State, SideEffect>, @KoinViewModel
{Name}Contract.kt — State data class + SideEffect sealed class
{Name}Intent.kt — Sealed class of user actions
di/
PresentationFeature{Name}Module.kt — @Module @ComponentScan
Data Flow¶
User gesture
│
▼
{Name}Screen.kt — translates gesture to Intent
│
▼
{Name}ViewModel.kt — intent { reduce { state.copy(...) } }
│ intent { postSideEffect(...) }
▼
ContainerHost container
│
├─ StateFlow<State> ──► {Name}Content.kt (recompose on change)
└─ SideEffect ────────► {Name}Screen.kt (one-shot: navigate, toast)
Code Pattern¶
Contract¶
data class SettingsState(
val theme: ThemeModel = ThemeModel.System,
val isLoading: Boolean = false,
)
sealed class SettingsSideEffect {
data object NavigateBack : SettingsSideEffect()
data class ShowError(val message: String) : SettingsSideEffect()
}
Intent¶
sealed class SettingsIntent {
data class SetTheme(val theme: ThemeModel) : SettingsIntent()
data object NavigateBack : SettingsIntent()
}
ViewModel¶
@KoinViewModel
class SettingsViewModel(
private val getThemeUseCase: GetThemeUseCase,
private val setThemeUseCase: SetThemeUseCase,
) : ContainerHost<SettingsState, SettingsSideEffect>, ViewModel() {
override val container = container<SettingsState, SettingsSideEffect>(SettingsState())
fun onIntent(intent: SettingsIntent) = when (intent) {
is SettingsIntent.SetTheme -> setTheme(intent.theme)
SettingsIntent.NavigateBack -> intent { postSideEffect(SettingsSideEffect.NavigateBack) }
}
private fun setTheme(theme: ThemeModel) = intent {
setThemeUseCase(theme)
.onSuccess { reduce { state.copy(theme = theme) } }
.onFailure { postSideEffect(SettingsSideEffect.ShowError(it.toString())) }
}
}
Screen (navigation entry point)¶
@Composable
fun SettingsScreen(navigator: AppNavigator) {
val viewModel = koinViewModel<SettingsViewModel>()
val state by viewModel.container.stateFlow.collectAsState()
viewModel.container.sideEffectFlow.collectWithLifecycle { effect ->
when (effect) {
SettingsSideEffect.NavigateBack -> navigator.navigateBack()
is SettingsSideEffect.ShowError -> { /* show snackbar */ }
}
}
SettingsContent(
state = state,
onIntent = viewModel::onIntent,
)
}
Content (stateless, testable)¶
@Composable
fun SettingsContent(
state: SettingsState,
onIntent: (SettingsIntent) -> Unit,
) {
// Pure UI — no ViewModel reference, no side effects
}
Dependency Injection¶
ViewModels are injected via @KoinViewModel and declared in the feature's DI module:
The module is included in AppModule.kt in presentation-core-application.