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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user