# Android MVI BaseViewModel Patterns

Full code examples backing the guidance in `SKILL.md`. Load this file when you
need a complete implementation to adapt. Headings mirror the SKILL.md sections.

## Contents

- [Assembled BaseViewModel](#assembled-baseviewmodel)
- [MVI contracts for a screen](#mvi-contracts-for-a-screen)
- [A concrete ViewModel](#a-concrete-viewmodel)
- [Lifecycle-aware event collector](#lifecycle-aware-event-collector)
- [Stateless screen composable](#stateless-screen-composable)
- [Testing the ViewModel](#testing-the-viewmodel)

## Assembled BaseViewModel

The complete base class combining state, events, intent dispatch, the use-case
executor, reference-counted loading, and the job registry.

```kotlin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

abstract class BaseViewModel<State, Intent, Event>(
    initialState: State,
) : ViewModel() {

    private val _state = MutableStateFlow(initialState)
    val state: StateFlow<State> = _state.asStateFlow()

    private val _events = Channel<Event>(Channel.RENDEZVOUS)
    val events: Flow<Event> = _events.receiveAsFlow()

    private val loadingCount = MutableStateFlow(0)
    val isLoading: StateFlow<Boolean> =
        loadingCount
            .map { it > 0 }
            .stateIn(viewModelScope, SharingStarted.Eagerly, false)

    private val jobs = mutableMapOf<String, Job>()

    fun processIntent(intent: Intent) = handleIntent(intent)

    protected abstract fun handleIntent(intent: Intent)

    protected open fun routeError(throwable: Throwable) = Unit

    protected fun reduce(reducer: State.() -> State) {
        _state.update { it.reducer() }
    }

    protected fun sendEvent(event: Event) {
        viewModelScope.launch { _events.send(event) }
    }

    protected fun <T> execute(
        showLoading: Boolean = true,
        onError: ((Throwable) -> Unit)? = null,
        block: suspend () -> T,
        onSuccess: (T) -> Unit,
    ): Job = viewModelScope.launch {
        if (showLoading) incrementLoading()
        try {
            runCatching { block() }
                .onSuccess(onSuccess)
                .onFailure { throwable ->
                    if (throwable is CancellationException) throw throwable
                    (onError ?: ::routeError)(throwable)
                }
        } finally {
            if (showLoading) decrementLoading()
        }
    }

    protected fun launchTracked(key: String, block: suspend () -> Unit): Job {
        jobs.remove(key)?.cancel()
        return viewModelScope.launch { block() }.also { jobs[key] = it }
    }

    protected fun cancel(key: String) {
        jobs.remove(key)?.cancel()
    }

    private fun incrementLoading() = loadingCount.update { it + 1 }

    private fun decrementLoading() =
        loadingCount.update { (it - 1).coerceAtLeast(0) }

    override fun onCleared() {
        jobs.values.forEach { it.cancel() }
        jobs.clear()
        super.onCleared()
    }
}
```

## MVI contracts for a screen

State is the render model, Intent is every input, Event is every one-shot
effect. Keep all three next to the screen they serve.

```kotlin
@Immutable
data class SearchState(
    val query: String = "",
    val results: List<ResultItem> = emptyList(),
    val error: String? = null,
)

sealed interface SearchIntent {
    data class QueryChanged(val text: String) : SearchIntent
    data object Submit : SearchIntent
    data object Retry : SearchIntent
}

sealed interface SearchEvent {
    data class OpenDetail(val id: Id) : SearchEvent
    data class ShowError(val message: String) : SearchEvent
}
```

## A concrete ViewModel

The subclass supplies the initial state, implements the exhaustive `when`, and
uses the inherited helpers - no coroutine, loading, or error boilerplate.

```kotlin
@HiltViewModel
class SearchViewModel @Inject constructor(
    private val search: SearchUseCase,
) : BaseViewModel<SearchState, SearchIntent, SearchEvent>(SearchState()) {

    override fun handleIntent(intent: SearchIntent) = when (intent) {
        is SearchIntent.QueryChanged -> reduce { copy(query = intent.text) }
        SearchIntent.Submit -> runSearch()
        SearchIntent.Retry -> runSearch()
    }

    private fun runSearch() {
        val query = state.value.query
        launchTracked("search") {
            execute(
                block = { search(query) },
                onError = { sendEvent(SearchEvent.ShowError(it.toUserMessage())) },
                onSuccess = { results -> reduce { copy(results = results, error = null) } },
            )
        }
    }

    override fun routeError(throwable: Throwable) {
        sendEvent(SearchEvent.ShowError(throwable.toUserMessage()))
    }
}
```

Keying `launchTracked("search")` makes each new submit cancel the previous
in-flight request automatically.

## Lifecycle-aware event collector

Collect one-shot events only while the screen is at least `STARTED`, so a
backgrounded screen never consumes a navigation. `flowWithLifecycle` restarts
collection across stop/start without leaking the previous coroutine.

```kotlin
@Composable
fun <Event> ObserveEvents(
    events: Flow<Event>,
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onEvent: (Event) -> Unit,
) {
    LaunchedEffect(events, lifecycleOwner) {
        events
            .flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
            .collect(onEvent)
    }
}
```

## Stateless screen composable

The screen receives a state value and lambdas only. It has no ViewModel
reference, so it renders in a `@Preview` and in tests without Hilt.

```kotlin
@Composable
fun SearchScreen(
    state: SearchState,
    isLoading: Boolean,
    onIntent: (SearchIntent) -> Unit,
) {
    Column {
        SearchField(
            value = state.query,
            onValueChange = { onIntent(SearchIntent.QueryChanged(it)) },
            onSubmit = { onIntent(SearchIntent.Submit) },
        )
        when {
            isLoading -> LoadingIndicator()
            state.error != null -> ErrorState(
                message = state.error,
                onRetry = { onIntent(SearchIntent.Retry) },
            )
            else -> ResultList(items = state.results)
        }
    }
}
```

## Testing the ViewModel

Drive intents, assert on state with Turbine, and assert one-shot events on the
`events` flow. Use a `TestDispatcher` so `viewModelScope` is controllable.

```kotlin
@Test
fun submit_emitsResults() = runTest {
    val viewModel = SearchViewModel(FakeSearchUseCase(results = sample))

    viewModel.state.test {
        assertEquals(SearchState(), awaitItem())

        viewModel.processIntent(SearchIntent.QueryChanged("abc"))
        assertEquals("abc", awaitItem().query)

        viewModel.processIntent(SearchIntent.Submit)
        assertEquals(sample, awaitItem().results)
    }
}

@Test
fun useCaseFailure_emitsShowErrorEvent() = runTest {
    val viewModel = SearchViewModel(FakeSearchUseCase(error = IOException()))

    viewModel.events.test {
        viewModel.processIntent(SearchIntent.Submit)
        assertIs<SearchEvent.ShowError>(awaitItem())
    }
}
```

Set the main dispatcher to a `StandardTestDispatcher` in `@Before` /
`Dispatchers.setMain(...)` and reset it in `@After` so `viewModelScope`
coroutines run under test control.
