feat(characters:presentation): UI-agnostic MVI ViewModel (REDI-87)

- CharacterListState (characters, isLoading, isLoadingNextPage, currentPage, endReached, error: UiText?),
  CharacterListAction (OnCharacterClick/OnRetry/OnLoadNextPage), CharacterListEvent (NavigateToDetail/ShowSnackbar).
- CharacterListViewModel: state via .update, one-time events via Channel, DataError -> UiText on failure,
  pagination persisted in SavedStateHandle (rebuilds list up to the saved page after process death).
- CharacterUi + Character.toCharacterUi().
- NO Compose/Views deps: verified no androidx.compose on the compile classpath. Stability via
  ImmutableList instead of @Stable (which would require compose-runtime) — the only compose-named
  transitive is kotlinx-immutable's annotations-only stub, not the Compose framework.
This commit is contained in:
2026-06-10 12:43:30 +02:00
parent 0bb96baa4d
commit 2a419df43e
7 changed files with 204 additions and 0 deletions

View File

@@ -17,4 +17,7 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.kotlinx.coroutines.android)
// Stable collection for state — makes the list Compose-stable WITHOUT a Compose dependency,
// so this module stays UI-agnostic (no @Stable annotation, which would require compose-runtime).
implementation(libs.kotlinx.collections.immutable)
}

View File

@@ -0,0 +1,7 @@
package com.example.architecture.feature.characters.presentation
sealed interface CharacterListAction {
data class OnCharacterClick(val characterId: Int) : CharacterListAction
data object OnRetry : CharacterListAction
data object OnLoadNextPage : CharacterListAction
}

View File

@@ -0,0 +1,8 @@
package com.example.architecture.feature.characters.presentation
import com.example.architecture.core.presentation.UiText
sealed interface CharacterListEvent {
data class NavigateToDetail(val characterId: Int) : CharacterListEvent
data class ShowSnackbar(val message: UiText) : CharacterListEvent
}

View File

@@ -0,0 +1,21 @@
package com.example.architecture.feature.characters.presentation
import com.example.architecture.core.presentation.UiText
import com.example.architecture.feature.characters.presentation.model.CharacterUi
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* The single source of UI state for the characters list. Deliberately Compose-free: instead of the
* `@Stable` annotation (which lives in compose-runtime), the list is an [ImmutableList], which
* Compose already treats as stable — so this module needs no Compose dependency. Navigation and
* snackbars are one-time Events, never state.
*/
data class CharacterListState(
val characters: ImmutableList<CharacterUi> = persistentListOf(),
val isLoading: Boolean = false,
val isLoadingNextPage: Boolean = false,
val currentPage: Int = 1,
val endReached: Boolean = false,
val error: UiText? = null,
)

View File

@@ -0,0 +1,142 @@
package com.example.architecture.feature.characters.presentation
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.architecture.core.domain.Result
import com.example.architecture.core.domain.onFailure
import com.example.architecture.core.domain.onSuccess
import com.example.architecture.core.presentation.UiText
import com.example.architecture.core.presentation.toUiText
import com.example.architecture.feature.characters.domain.CharacterRepository
import com.example.architecture.feature.characters.presentation.model.CharacterUi
import com.example.architecture.feature.characters.presentation.model.toCharacterUi
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* UI-agnostic MVI ViewModel for the characters list. Shared by BOTH the Compose and the Views
* renderers. Updates [CharacterListState] only via `.update`, emits one-time [CharacterListEvent]s
* via a [Channel], maps failures to [UiText], and persists the loaded page in [SavedStateHandle].
*/
class CharacterListViewModel(
private val characterRepository: CharacterRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val _state = MutableStateFlow(CharacterListState())
val state = _state.asStateFlow()
private val _events = Channel<CharacterListEvent>()
val events = _events.receiveAsFlow()
init {
// After process death, rebuild the list up to the highest page that had been loaded.
val restoredPage: Int = savedStateHandle[KEY_PAGE] ?: 1
restore(restoredPage)
}
fun onAction(action: CharacterListAction) {
when (action) {
is CharacterListAction.OnCharacterClick -> viewModelScope.launch {
_events.send(CharacterListEvent.NavigateToDetail(action.characterId))
}
CharacterListAction.OnRetry -> retry()
CharacterListAction.OnLoadNextPage -> loadNextPage()
}
}
private fun restore(targetPage: Int) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
val accumulated = mutableListOf<CharacterUi>()
var lastLoadedPage = 0
var endReached = false
var error: UiText? = null
var page = 1
while (page <= targetPage) {
when (val result = characterRepository.getCharacters(page)) {
is Result.Success -> {
accumulated += result.data.characters.map { it.toCharacterUi() }
lastLoadedPage = page
endReached = result.data.nextPage == null
if (endReached) break
}
is Result.Error -> {
error = result.error.toUiText()
break
}
}
page++
}
if (accumulated.isEmpty() && error != null) {
_state.update { it.copy(isLoading = false, error = error) }
_events.send(CharacterListEvent.ShowSnackbar(error))
} else {
val loadedPage = lastLoadedPage.coerceAtLeast(1)
_state.update {
it.copy(
characters = accumulated.toImmutableList(),
isLoading = false,
currentPage = loadedPage,
endReached = endReached,
error = null,
)
}
savedStateHandle[KEY_PAGE] = loadedPage
}
}
}
private fun loadNextPage() {
val current = _state.value
if (current.isLoading || current.isLoadingNextPage || current.endReached) return
loadPage(page = current.currentPage + 1)
}
private fun retry() {
val current = _state.value
if (current.characters.isEmpty()) {
restore(savedStateHandle[KEY_PAGE] ?: 1)
} else {
loadPage(page = current.currentPage + 1)
}
}
private fun loadPage(page: Int) {
viewModelScope.launch {
_state.update { it.copy(isLoadingNextPage = true, error = null) }
characterRepository.getCharacters(page)
.onSuccess { pageData ->
_state.update { state ->
state.copy(
characters = (state.characters + pageData.characters.map { it.toCharacterUi() })
.toImmutableList(),
isLoadingNextPage = false,
currentPage = page,
endReached = pageData.nextPage == null,
error = null,
)
}
savedStateHandle[KEY_PAGE] = page
}
.onFailure { failure ->
val message = failure.toUiText()
_state.update { it.copy(isLoadingNextPage = false, error = message) }
_events.send(CharacterListEvent.ShowSnackbar(message))
}
}
}
private companion object {
const val KEY_PAGE = "currentPage"
}
}

View File

@@ -0,0 +1,21 @@
package com.example.architecture.feature.characters.presentation.model
import com.example.architecture.feature.characters.domain.model.Character
import com.example.architecture.feature.characters.domain.model.CharacterStatus
/** Presentation model for a character list item — decouples the UI from the domain [Character]. */
data class CharacterUi(
val id: Int,
val name: String,
val species: String,
val imageUrl: String,
val status: CharacterStatus,
)
fun Character.toCharacterUi(): CharacterUi = CharacterUi(
id = id,
name = name,
species = species,
imageUrl = imageUrl,
status = status,
)

View File

@@ -18,6 +18,7 @@ composeBom = "2026.03.01"
# Async / serialization
coroutines = "1.10.2"
kotlinxSerialization = "1.8.1"
kotlinxCollectionsImmutable = "0.3.8"
# DI
koin = "4.1.0"
@@ -87,6 +88,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
# --- Koin (versions via BOM) ---
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }