diff --git a/feature/characters/presentation/build.gradle.kts b/feature/characters/presentation/build.gradle.kts index a6ff842..00837dd 100644 --- a/feature/characters/presentation/build.gradle.kts +++ b/feature/characters/presentation/build.gradle.kts @@ -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) } diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListAction.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListAction.kt new file mode 100644 index 0000000..6636594 --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListAction.kt @@ -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 +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListEvent.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListEvent.kt new file mode 100644 index 0000000..d0ce301 --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListEvent.kt @@ -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 +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListState.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListState.kt new file mode 100644 index 0000000..a3c54c8 --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListState.kt @@ -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 = persistentListOf(), + val isLoading: Boolean = false, + val isLoadingNextPage: Boolean = false, + val currentPage: Int = 1, + val endReached: Boolean = false, + val error: UiText? = null, +) diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModel.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModel.kt new file mode 100644 index 0000000..3858449 --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModel.kt @@ -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() + 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() + 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" + } +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/model/CharacterUi.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/model/CharacterUi.kt new file mode 100644 index 0000000..ad32d46 --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/model/CharacterUi.kt @@ -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, +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4b33f3e..7f7ac89 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }