From 600f12259d587c3f0b23f273f8b12808a5f90375 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 12:31:59 +0200 Subject: [PATCH 1/6] feat(characters:domain): models + CharacterRepository interface (REDI-85) - Character, CharacterStatus, CharactersPage(characters, nextPage), CharacterDetails. - CharacterRepository interface returning Result and Result. Pure Kotlin, no serialization annotations, no Android. --- .../characters/domain/CharacterRepository.kt | 17 +++++++++++++++++ .../characters/domain/model/Character.kt | 10 ++++++++++ .../characters/domain/model/CharacterDetails.kt | 15 +++++++++++++++ .../characters/domain/model/CharacterStatus.kt | 8 ++++++++ .../characters/domain/model/CharactersPage.kt | 7 +++++++ 5 files changed, 57 insertions(+) create mode 100644 feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/CharacterRepository.kt create mode 100644 feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/Character.kt create mode 100644 feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/CharacterDetails.kt create mode 100644 feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/CharacterStatus.kt create mode 100644 feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/CharactersPage.kt diff --git a/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/CharacterRepository.kt b/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/CharacterRepository.kt new file mode 100644 index 0000000..7cf27d1 --- /dev/null +++ b/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/CharacterRepository.kt @@ -0,0 +1,17 @@ +package com.example.architecture.feature.characters.domain + +import com.example.architecture.core.domain.DataError +import com.example.architecture.core.domain.Result +import com.example.architecture.feature.characters.domain.model.CharacterDetails +import com.example.architecture.feature.characters.domain.model.CharactersPage + +/** + * Contract for the characters data layer. Lives in domain so presentation never depends on data. + * Returns the [DataError] supertype because an implementation may merge sources (e.g. an + * offline-first repository combining network + local). + */ +interface CharacterRepository { + suspend fun getCharacters(page: Int): Result + + suspend fun getCharacterDetails(id: Int): Result +} diff --git a/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/Character.kt b/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/Character.kt new file mode 100644 index 0000000..1791113 --- /dev/null +++ b/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/Character.kt @@ -0,0 +1,10 @@ +package com.example.architecture.feature.characters.domain.model + +/** A character as shown in the list. */ +data class Character( + val id: Int, + val name: String, + val status: CharacterStatus, + val species: String, + val imageUrl: String, +) diff --git a/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/CharacterDetails.kt b/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/CharacterDetails.kt new file mode 100644 index 0000000..208323a --- /dev/null +++ b/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/CharacterDetails.kt @@ -0,0 +1,15 @@ +package com.example.architecture.feature.characters.domain.model + +/** Full character profile shown on the detail screen. */ +data class CharacterDetails( + val id: Int, + val name: String, + val status: CharacterStatus, + val species: String, + val type: String, + val gender: String, + val origin: String, + val location: String, + val imageUrl: String, + val episodeCount: Int, +) diff --git a/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/CharacterStatus.kt b/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/CharacterStatus.kt new file mode 100644 index 0000000..639844f --- /dev/null +++ b/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/CharacterStatus.kt @@ -0,0 +1,8 @@ +package com.example.architecture.feature.characters.domain.model + +/** Life status of a character. Mapped from the API's string in the data layer. */ +enum class CharacterStatus { + ALIVE, + DEAD, + UNKNOWN, +} diff --git a/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/CharactersPage.kt b/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/CharactersPage.kt new file mode 100644 index 0000000..f5f8546 --- /dev/null +++ b/feature/characters/domain/src/main/kotlin/com/example/architecture/feature/characters/domain/model/CharactersPage.kt @@ -0,0 +1,7 @@ +package com.example.architecture.feature.characters.domain.model + +/** One page of characters plus the next page index ([nextPage] is null when there are no more). */ +data class CharactersPage( + val characters: List, + val nextPage: Int?, +) From 0bb96baa4d963f9647334b6cecb9b267029be23d Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 12:37:40 +0200 Subject: [PATCH 2/6] feat(characters:data): DTOs, mappers, data source, repo, Koin module (REDI-86) - @Serializable CharacterDto/CharactersResponseDto/PageInfoDto in dto/. - mappers/CharacterMapper.kt: internal, pure toDomain()/toCharacter()/toCharacterDetails(); nextPage parsed from info.next URL. No mapping inside DTO/data-source classes. - KtorCharacterDataSource via the typed HttpClient.get helpers (errors -> DataError.Network). - NetworkCharacterRepository (not *Impl) maps DTO -> domain; DataError.Network widens to DataError. - charactersDataModule: singleOf(::KtorCharacterDataSource) + singleOf(::NetworkCharacterRepository) { bind() }. - core:data: expose ktor-client-core as api (public inline helpers are inlined into consumers) and move Timber logging into a @PublishedApi internal fn so Timber doesn't leak across modules. --- core/data/build.gradle.kts | 3 ++ .../core/data/network/HttpClientExt.kt | 18 ++++++-- .../data/NetworkCharacterRepository.kt | 25 +++++++++++ .../datasource/KtorCharacterDataSource.kt | 22 ++++++++++ .../data/di/CharactersDataModule.kt | 13 ++++++ .../characters/data/dto/CharacterDto.kt | 23 ++++++++++ .../data/dto/CharactersResponseDto.kt | 9 ++++ .../characters/data/dto/PageInfoDto.kt | 11 +++++ .../data/mappers/CharacterMapper.kt | 44 +++++++++++++++++++ 9 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepository.kt create mode 100644 feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/datasource/KtorCharacterDataSource.kt create mode 100644 feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/di/CharactersDataModule.kt create mode 100644 feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharacterDto.kt create mode 100644 feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharactersResponseDto.kt create mode 100644 feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/PageInfoDto.kt create mode 100644 feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/mappers/CharacterMapper.kt diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index c12abdf..f5177bd 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -19,4 +19,7 @@ android { dependencies { implementation(project(":core:domain")) implementation(libs.timber) + // `api`: the public inline HttpClient.get/post/delete helpers are inlined into consumer modules, + // so those modules need the Ktor request/response types on their compile classpath. + api(libs.ktor.client.core) } diff --git a/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt index f2fb972..b14caa1 100644 --- a/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt +++ b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt @@ -65,21 +65,31 @@ suspend inline fun safeCall( return try { responseToResult(execute()) } catch (e: UnresolvedAddressException) { - Timber.tag("HttpClient").e(e, "No internet (unresolved address)") + logNetworkError(e, "No internet (unresolved address)") Result.Error(DataError.Network.NO_INTERNET) } catch (e: UnknownHostException) { - Timber.tag("HttpClient").e(e, "No internet (unknown host)") + logNetworkError(e, "No internet (unknown host)") Result.Error(DataError.Network.NO_INTERNET) } catch (e: SerializationException) { - Timber.tag("HttpClient").e(e, "Serialization failure") + logNetworkError(e, "Serialization failure") Result.Error(DataError.Network.SERIALIZATION) } catch (e: Exception) { if (e is CancellationException) throw e - Timber.tag("HttpClient").e(e, "Unknown network failure") + logNetworkError(e, "Unknown network failure") Result.Error(DataError.Network.UNKNOWN) } } +/** + * Logs a caught network error. `@PublishedApi internal` so the public inline [safeCall] can call it + * across modules WITHOUT leaking Timber: the Timber dependency stays inside `:core:data` because + * this function's body is not inlined into the caller. + */ +@PublishedApi +internal fun logNetworkError(throwable: Throwable, message: String) { + Timber.tag("HttpClient").e(throwable, message) +} + /** Maps HTTP status codes to typed [DataError.Network] (extends the skill table with 400/403/404). */ suspend inline fun responseToResult( response: HttpResponse, diff --git a/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepository.kt b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepository.kt new file mode 100644 index 0000000..27567d0 --- /dev/null +++ b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/NetworkCharacterRepository.kt @@ -0,0 +1,25 @@ +package com.example.architecture.feature.characters.data + +import com.example.architecture.core.domain.DataError +import com.example.architecture.core.domain.Result +import com.example.architecture.core.domain.map +import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource +import com.example.architecture.feature.characters.data.mappers.toCharacterDetails +import com.example.architecture.feature.characters.data.mappers.toDomain +import com.example.architecture.feature.characters.domain.CharacterRepository +import com.example.architecture.feature.characters.domain.model.CharacterDetails +import com.example.architecture.feature.characters.domain.model.CharactersPage + +/** + * Network-backed [CharacterRepository]. Maps DTOs to domain via the mappers; the `Result`'s + * `DataError.Network` widens to the `DataError` supertype through `Result`'s covariance. + */ +internal class NetworkCharacterRepository( + private val dataSource: KtorCharacterDataSource, +) : CharacterRepository { + override suspend fun getCharacters(page: Int): Result = + dataSource.getCharacters(page).map { it.toDomain() } + + override suspend fun getCharacterDetails(id: Int): Result = + dataSource.getCharacter(id).map { it.toCharacterDetails() } +} diff --git a/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/datasource/KtorCharacterDataSource.kt b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/datasource/KtorCharacterDataSource.kt new file mode 100644 index 0000000..8b6298b --- /dev/null +++ b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/datasource/KtorCharacterDataSource.kt @@ -0,0 +1,22 @@ +package com.example.architecture.feature.characters.data.datasource + +import com.example.architecture.core.data.network.get +import com.example.architecture.core.domain.DataError +import com.example.architecture.core.domain.Result +import com.example.architecture.feature.characters.data.dto.CharacterDto +import com.example.architecture.feature.characters.data.dto.CharactersResponseDto +import io.ktor.client.HttpClient + +/** + * Remote data source for characters. Returns raw DTOs (no mapping here — the repository maps via + * CharacterMapper). Errors already surface as [DataError.Network] from the typed `get` helper. + */ +internal class KtorCharacterDataSource( + private val httpClient: HttpClient, +) { + suspend fun getCharacters(page: Int): Result = + httpClient.get(route = "/character", queryParameters = mapOf("page" to page)) + + suspend fun getCharacter(id: Int): Result = + httpClient.get(route = "/character/$id") +} diff --git a/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/di/CharactersDataModule.kt b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/di/CharactersDataModule.kt new file mode 100644 index 0000000..439f64b --- /dev/null +++ b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/di/CharactersDataModule.kt @@ -0,0 +1,13 @@ +package com.example.architecture.feature.characters.data.di + +import com.example.architecture.feature.characters.data.NetworkCharacterRepository +import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource +import com.example.architecture.feature.characters.domain.CharacterRepository +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +val charactersDataModule = module { + singleOf(::KtorCharacterDataSource) + singleOf(::NetworkCharacterRepository) { bind() } +} diff --git a/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharacterDto.kt b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharacterDto.kt new file mode 100644 index 0000000..0bbb796 --- /dev/null +++ b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharacterDto.kt @@ -0,0 +1,23 @@ +package com.example.architecture.feature.characters.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CharacterDto( + val id: Int, + val name: String, + val status: String, + val species: String, + val type: String, + val gender: String, + val origin: LocationRefDto, + val location: LocationRefDto, + val image: String, + val episode: List, +) + +@Serializable +data class LocationRefDto( + val name: String, + val url: String, +) diff --git a/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharactersResponseDto.kt b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharactersResponseDto.kt new file mode 100644 index 0000000..17e56ff --- /dev/null +++ b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/CharactersResponseDto.kt @@ -0,0 +1,9 @@ +package com.example.architecture.feature.characters.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CharactersResponseDto( + val info: PageInfoDto, + val results: List, +) diff --git a/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/PageInfoDto.kt b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/PageInfoDto.kt new file mode 100644 index 0000000..73eccb2 --- /dev/null +++ b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/dto/PageInfoDto.kt @@ -0,0 +1,11 @@ +package com.example.architecture.feature.characters.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class PageInfoDto( + val count: Int, + val pages: Int, + val next: String?, + val prev: String?, +) diff --git a/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/mappers/CharacterMapper.kt b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/mappers/CharacterMapper.kt new file mode 100644 index 0000000..f727019 --- /dev/null +++ b/feature/characters/data/src/main/kotlin/com/example/architecture/feature/characters/data/mappers/CharacterMapper.kt @@ -0,0 +1,44 @@ +package com.example.architecture.feature.characters.data.mappers + +import com.example.architecture.feature.characters.data.dto.CharacterDto +import com.example.architecture.feature.characters.data.dto.CharactersResponseDto +import com.example.architecture.feature.characters.domain.model.Character +import com.example.architecture.feature.characters.domain.model.CharacterDetails +import com.example.architecture.feature.characters.domain.model.CharacterStatus +import com.example.architecture.feature.characters.domain.model.CharactersPage + +internal fun CharactersResponseDto.toDomain(): CharactersPage = CharactersPage( + characters = results.map { it.toCharacter() }, + nextPage = info.next?.toPageNumber(), +) + +internal fun CharacterDto.toCharacter(): Character = Character( + id = id, + name = name, + status = status.toCharacterStatus(), + species = species, + imageUrl = image, +) + +internal fun CharacterDto.toCharacterDetails(): CharacterDetails = CharacterDetails( + id = id, + name = name, + status = status.toCharacterStatus(), + species = species, + type = type, + gender = gender, + origin = origin.name, + location = location.name, + imageUrl = image, + episodeCount = episode.size, +) + +private fun String.toCharacterStatus(): CharacterStatus = when (lowercase()) { + "alive" -> CharacterStatus.ALIVE + "dead" -> CharacterStatus.DEAD + else -> CharacterStatus.UNKNOWN +} + +/** The API's `next` is a full URL like `.../character?page=2`; pull the page number out of it. */ +private fun String.toPageNumber(): Int? = + Regex("[?&]page=(\\d+)").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() From 2a419df43eb9b61f0d2dd079f0934a2e076c1490 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 12:43:30 +0200 Subject: [PATCH 3/6] feat(characters:presentation): UI-agnostic MVI ViewModel (REDI-87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../characters/presentation/build.gradle.kts | 3 + .../presentation/CharacterListAction.kt | 7 + .../presentation/CharacterListEvent.kt | 8 + .../presentation/CharacterListState.kt | 21 +++ .../presentation/CharacterListViewModel.kt | 142 ++++++++++++++++++ .../presentation/model/CharacterUi.kt | 21 +++ gradle/libs.versions.toml | 2 + 7 files changed, 204 insertions(+) create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListAction.kt create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListEvent.kt create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListState.kt create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModel.kt create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/model/CharacterUi.kt 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" } From dd4576409d24faf55b3d2fdcf4b10ac611180409 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 12:48:21 +0200 Subject: [PATCH 4/6] feat(characters:presentation-compose): list renderer, Root/Screen split, previews (REDI-88) - CharacterListRoot: koinViewModel(), ObserveAsEvents, forwards nav + shows snackbar via Context asString. - CharacterListScreen: pure state+onAction; AppScaffold + LazyColumn (key=id), design-system NetworkImage (contentDescription)/AppCard, loading/error/empty states, snapshot-based scroll-to-end -> OnLoadNextPage (ViewModel guards duplicates). - Loaded + error previews wrapped in AppTheme. - feature:characters:presentation now exposes kotlinx-immutable as api (ImmutableList is in the state API). --- .../compose/CharacterListScreen.kt | 263 ++++++++++++++++++ .../src/main/res/values/strings.xml | 8 + .../characters/presentation/build.gradle.kts | 3 +- 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt create mode 100644 feature/characters/presentation-compose/src/main/res/values/strings.xml diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt new file mode 100644 index 0000000..091ec3e --- /dev/null +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt @@ -0,0 +1,263 @@ +package com.example.architecture.feature.characters.presentation.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.architecture.core.design.system.component.AppCard +import com.example.architecture.core.design.system.component.AppScaffold +import com.example.architecture.core.design.system.component.ErrorState +import com.example.architecture.core.design.system.component.LoadingIndicator +import com.example.architecture.core.design.system.component.NetworkImage +import com.example.architecture.core.design.system.theme.AppTheme +import com.example.architecture.core.presentation.ObserveAsEvents +import com.example.architecture.core.presentation.asString +import com.example.architecture.feature.characters.domain.model.CharacterStatus +import com.example.architecture.feature.characters.presentation.CharacterListAction +import com.example.architecture.feature.characters.presentation.CharacterListEvent +import com.example.architecture.feature.characters.presentation.CharacterListState +import com.example.architecture.feature.characters.presentation.CharacterListViewModel +import com.example.architecture.feature.characters.presentation.model.CharacterUi +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel + +/** + * Root: owns the ViewModel (via Koin), observes one-time Events, and forwards navigation up. + * The snackbar is resolved with the Context-based [asString] because it runs outside composition. + */ +@Composable +fun CharacterListRoot( + onCharacterClick: (Int) -> Unit, + viewModel: CharacterListViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + ObserveAsEvents(viewModel.events) { event -> + when (event) { + is CharacterListEvent.NavigateToDetail -> onCharacterClick(event.characterId) + is CharacterListEvent.ShowSnackbar -> scope.launch { + snackbarHostState.showSnackbar(event.message.asString(context)) + } + } + } + + CharacterListScreen( + state = state, + onAction = viewModel::onAction, + snackbarHostState = snackbarHostState, + ) +} + +/** Pure, stateless screen — previewable without a ViewModel. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CharacterListScreen( + state: CharacterListState, + onAction: (CharacterListAction) -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, +) { + AppScaffold( + topBar = { TopAppBar(title = { Text(stringResource(R.string.characters_title)) }) }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // Local val so the nullable cross-module `error` can smart-cast inside the branch. + val error = state.error + when { + state.isLoading -> LoadingIndicator() + + error != null && state.characters.isEmpty() -> ErrorState( + message = error.asString(), + onRetry = { onAction(CharacterListAction.OnRetry) }, + ) + + state.characters.isEmpty() -> EmptyState() + + else -> CharacterList(state = state, onAction = onAction) + } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } +} + +@Composable +private fun CharacterList( + state: CharacterListState, + onAction: (CharacterListAction) -> Unit, +) { + val listState = rememberLazyListState() + + // Trigger paging from the snapshot-backed list state only; the ViewModel guards against + // duplicate/just-loading/end-reached requests, so the composable stays simple. + val shouldLoadMore by remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val total = layoutInfo.totalItemsCount + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + total > 0 && lastVisible >= total - 1 + } + } + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) onAction(CharacterListAction.OnLoadNextPage) + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(items = state.characters, key = { it.id }) { character -> + CharacterListItem( + character = character, + onClick = { onAction(CharacterListAction.OnCharacterClick(character.id)) }, + ) + } + if (state.isLoadingNextPage) { + item { + Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } + } +} + +@Composable +private fun CharacterListItem( + character: CharacterUi, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AppCard(modifier = modifier.fillMaxWidth(), onClick = onClick) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + NetworkImage( + imageUrl = character.imageUrl, + contentDescription = stringResource(R.string.cd_character_avatar, character.name), + modifier = Modifier + .size(64.dp) + .clip(CircleShape), + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = character.name, style = MaterialTheme.typography.titleMedium) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(character.status.indicatorColor(), CircleShape), + ) + Text( + text = stringResource(character.status.labelRes()) + " · " + character.species, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun EmptyState() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.characters_empty), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } +} + +private fun CharacterStatus.labelRes(): Int = when (this) { + CharacterStatus.ALIVE -> R.string.status_alive + CharacterStatus.DEAD -> R.string.status_dead + CharacterStatus.UNKNOWN -> R.string.status_unknown +} + +private fun CharacterStatus.indicatorColor(): Color = when (this) { + CharacterStatus.ALIVE -> Color(0xFF4CAF50) + CharacterStatus.DEAD -> Color(0xFFE53935) + CharacterStatus.UNKNOWN -> Color(0xFF9E9E9E) +} + +private val previewCharacters = persistentListOf( + CharacterUi(1, "Rick Sanchez", "Human", "", CharacterStatus.ALIVE), + CharacterUi(2, "Morty Smith", "Human", "", CharacterStatus.ALIVE), + CharacterUi(3, "Birdperson", "Bird-Person", "", CharacterStatus.DEAD), +) + +@Preview +@Composable +private fun CharacterListScreenLoadedPreview() { + AppTheme { + CharacterListScreen( + state = CharacterListState(characters = previewCharacters), + onAction = {}, + ) + } +} + +@Preview +@Composable +private fun CharacterListScreenErrorPreview() { + AppTheme { + CharacterListScreen( + state = CharacterListState( + error = com.example.architecture.core.presentation.UiText.DynamicString( + "No internet connection.", + ), + ), + onAction = {}, + ) + } +} diff --git a/feature/characters/presentation-compose/src/main/res/values/strings.xml b/feature/characters/presentation-compose/src/main/res/values/strings.xml new file mode 100644 index 0000000..0b9f957 --- /dev/null +++ b/feature/characters/presentation-compose/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + Characters + No characters to show. + Avatar of %1$s + Alive + Dead + Unknown + diff --git a/feature/characters/presentation/build.gradle.kts b/feature/characters/presentation/build.gradle.kts index 00837dd..e8e8a28 100644 --- a/feature/characters/presentation/build.gradle.kts +++ b/feature/characters/presentation/build.gradle.kts @@ -19,5 +19,6 @@ dependencies { 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) + // `api` because CharacterListState.characters exposes ImmutableList in the public state API. + api(libs.kotlinx.collections.immutable) } From ef50094e3e7a61e62e96c02c61aefeff2e2918ef Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 12:52:14 +0200 Subject: [PATCH 5/6] feat(characters): Koin module + nav graph + wire into :app (REDI-89) - charactersPresentationModule: viewModelOf(::CharacterListViewModel) (in the UI-agnostic module). - @Serializable CharacterListRoute + NavGraphBuilder.charactersGraph { composable } in presentation-compose (serialization plugin added for type-safe routes). - :app registers coreDataModule + charactersDataModule + charactersPresentationModule in startKoin, and hosts a NavHost(startDestination = CharacterListRoute) calling charactersGraph. - core:data manifest declares INTERNET (merges into :app) for live API calls. --- app/build.gradle.kts | 6 ++++ .../example/architecture/ArchitectureApp.kt | 10 +++++-- .../com/example/architecture/MainActivity.kt | 29 ++++++++----------- core/data/src/main/AndroidManifest.xml | 7 +++++ .../presentation-compose/build.gradle.kts | 2 ++ .../compose/CharactersNavigation.kt | 21 ++++++++++++++ .../di/CharactersPresentationModule.kt | 10 +++++++ 7 files changed, 66 insertions(+), 19 deletions(-) create mode 100644 core/data/src/main/AndroidManifest.xml create mode 100644 feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt create mode 100644 feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 63d4afe..fd105f7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,10 +16,16 @@ dependencies { implementation(project(":core:data")) implementation(project(":core:design-system")) + // Characters feature: data + presentation (Koin modules) + Compose renderer (nav graph). + implementation(project(":feature:characters:data")) + implementation(project(":feature:characters:presentation")) + implementation(project(":feature:characters:presentation-compose")) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.bundles.lifecycle.compose) + implementation(libs.androidx.navigation.compose) // Material Components — required for the Material3 XML Activity theme. implementation(libs.material) // Logging — the DebugTree is planted here; other modules log via Timber's static API. diff --git a/app/src/main/kotlin/com/example/architecture/ArchitectureApp.kt b/app/src/main/kotlin/com/example/architecture/ArchitectureApp.kt index 3005658..6842088 100644 --- a/app/src/main/kotlin/com/example/architecture/ArchitectureApp.kt +++ b/app/src/main/kotlin/com/example/architecture/ArchitectureApp.kt @@ -2,14 +2,16 @@ package com.example.architecture import android.app.Application import com.example.architecture.core.data.di.coreDataModule +import com.example.architecture.feature.characters.data.di.charactersDataModule +import com.example.architecture.feature.characters.presentation.di.charactersPresentationModule import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin import timber.log.Timber /** - * Single Koin entry point. Feature modules append their own `*DataModule` / `*PresentationModule` - * to the [modules] list — assembly happens only here, never inside feature modules. + * Single Koin entry point. Every feature's `*DataModule` / `*PresentationModule` is assembled here, + * never inside feature modules. */ class ArchitectureApp : Application() { override fun onCreate() { @@ -24,7 +26,11 @@ class ArchitectureApp : Application() { androidLogger() androidContext(this@ArchitectureApp) modules( + // core coreDataModule, + // characters feature + charactersDataModule, + charactersPresentationModule, ) } } diff --git a/app/src/main/kotlin/com/example/architecture/MainActivity.kt b/app/src/main/kotlin/com/example/architecture/MainActivity.kt index abbb074..bbeab40 100644 --- a/app/src/main/kotlin/com/example/architecture/MainActivity.kt +++ b/app/src/main/kotlin/com/example/architecture/MainActivity.kt @@ -4,31 +4,26 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import com.example.architecture.core.design.system.component.AppScaffold +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController import com.example.architecture.core.design.system.theme.AppTheme +import com.example.architecture.feature.characters.presentation.compose.CharacterListRoute +import com.example.architecture.feature.characters.presentation.compose.charactersGraph class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - // Compose themes via AppTheme; the navigation host lands in a later milestone. AppTheme { - AppScaffold { innerPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center, - ) { - Text(text = "Android Architecture Showcase") - } + val navController = rememberNavController() + NavHost( + navController = navController, + startDestination = CharacterListRoute, + ) { + charactersGraph( + onCharacterClick = { /* Detail navigation is wired in the next milestone. */ }, + ) } } } diff --git a/core/data/src/main/AndroidManifest.xml b/core/data/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f15e066 --- /dev/null +++ b/core/data/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/feature/characters/presentation-compose/build.gradle.kts b/feature/characters/presentation-compose/build.gradle.kts index c9981f0..016b9c0 100644 --- a/feature/characters/presentation-compose/build.gradle.kts +++ b/feature/characters/presentation-compose/build.gradle.kts @@ -1,5 +1,7 @@ plugins { alias(libs.plugins.architecture.android.feature) + // For @Serializable type-safe navigation routes. + alias(libs.plugins.architecture.kotlinx.serialization) } android { diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt new file mode 100644 index 0000000..3ae9849 --- /dev/null +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt @@ -0,0 +1,21 @@ +package com.example.architecture.feature.characters.presentation.compose + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import kotlinx.serialization.Serializable + +/** Type-safe route for the characters list screen. */ +@Serializable +data object CharacterListRoute + +/** + * The characters feature nav graph. `:app` only calls this and supplies cross-screen navigation as + * a callback. The detail destination is added here in a later milestone. + */ +fun NavGraphBuilder.charactersGraph( + onCharacterClick: (Int) -> Unit, +) { + composable { + CharacterListRoot(onCharacterClick = onCharacterClick) + } +} diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt new file mode 100644 index 0000000..466b610 --- /dev/null +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt @@ -0,0 +1,10 @@ +package com.example.architecture.feature.characters.presentation.di + +import com.example.architecture.feature.characters.presentation.CharacterListViewModel +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +/** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModel it provides. */ +val charactersPresentationModule = module { + viewModelOf(::CharacterListViewModel) +} From 38d8f5915bc1f2d36f619b7f70c0b4dd6c994f9f Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 13:03:09 +0200 Subject: [PATCH 6/6] fix(characters:presentation): pagination race + silent restore failure (review) - Race: isLoadingNextPage was set inside the launched coroutine, so a rapid second OnLoadNextPage passed the guard before the flag flipped -> the same page loaded twice and items were appended twice. Set the loading flag synchronously before launching. - Restore: when a middle page failed after earlier pages loaded, the error was swallowed (error=null, no event). Now any restore failure emits a ShowSnackbar; partial restores show the loaded list + snackbar, full failures show the error state. Found by the milestone review. --- .../presentation/CharacterListViewModel.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) 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 index 3858449..c131025 100644 --- 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 @@ -52,9 +52,9 @@ class CharacterListViewModel( } private fun restore(targetPage: Int) { + // Flip the flag synchronously so a guard reading state sees it immediately. + _state.update { it.copy(isLoading = true, error = null) } viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - val accumulated = mutableListOf() var lastLoadedPage = 0 var endReached = false @@ -77,9 +77,14 @@ class CharacterListViewModel( page++ } - if (accumulated.isEmpty() && error != null) { - _state.update { it.copy(isLoading = false, error = error) } + // Always surface a failure — even a partial one where earlier pages loaded. + if (error != null) { _events.send(CharacterListEvent.ShowSnackbar(error)) + } + + if (accumulated.isEmpty()) { + // Nothing loaded → full-screen error (or an empty list if the API simply had none). + _state.update { it.copy(isLoading = false, error = error) } } else { val loadedPage = lastLoadedPage.coerceAtLeast(1) _state.update { @@ -88,6 +93,7 @@ class CharacterListViewModel( isLoading = false, currentPage = loadedPage, endReached = endReached, + // The list is shown; the snackbar already surfaced any partial failure. error = null, ) } @@ -112,8 +118,11 @@ class CharacterListViewModel( } private fun loadPage(page: Int) { + // Flip the loading flag SYNCHRONOUSLY (before launching) so a rapid second OnLoadNextPage is + // guarded out before its coroutine starts — otherwise the same page loads twice and items + // get appended twice. + _state.update { it.copy(isLoadingNextPage = true, error = null) } viewModelScope.launch { - _state.update { it.copy(isLoadingNextPage = true, error = null) } characterRepository.getCharacters(page) .onSuccess { pageData -> _state.update { state ->