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.ktx)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
|
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
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
|
# Async / serialization
|
||||||
coroutines = "1.10.2"
|
coroutines = "1.10.2"
|
||||||
kotlinxSerialization = "1.8.1"
|
kotlinxSerialization = "1.8.1"
|
||||||
|
kotlinxCollectionsImmutable = "0.3.8"
|
||||||
|
|
||||||
# DI
|
# DI
|
||||||
koin = "4.1.0"
|
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-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-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-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 (versions via BOM) ---
|
||||||
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
|
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
|
||||||
|
|||||||
Reference in New Issue
Block a user