Merge pull request #2 from AdrianKuta/feat/flagship-mvi

Flagship MVI Feature (REDI-85…89)
This commit is contained in:
2026-06-10 13:13:17 +02:00
committed by GitHub
30 changed files with 772 additions and 23 deletions

View File

@@ -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.

View File

@@ -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,
)
}
}

View File

@@ -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. */ },
)
}
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Networking lives in this module, so the permission is declared here and merges into :app. -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -65,21 +65,31 @@ suspend inline fun <reified T> 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 <reified T> responseToResult(
response: HttpResponse,

View File

@@ -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<CharactersPage, DataError> =
dataSource.getCharacters(page).map { it.toDomain() }
override suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError> =
dataSource.getCharacter(id).map { it.toCharacterDetails() }
}

View File

@@ -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<CharactersResponseDto, DataError.Network> =
httpClient.get(route = "/character", queryParameters = mapOf("page" to page))
suspend fun getCharacter(id: Int): Result<CharacterDto, DataError.Network> =
httpClient.get(route = "/character/$id")
}

View File

@@ -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<CharacterRepository>() }
}

View File

@@ -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<String>,
)
@Serializable
data class LocationRefDto(
val name: String,
val url: String,
)

View File

@@ -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<CharacterDto>,
)

View File

@@ -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?,
)

View File

@@ -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()

View File

@@ -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<CharactersPage, DataError>
suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError>
}

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
}

View File

@@ -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<Character>,
val nextPage: Int?,
)

View File

@@ -1,5 +1,7 @@
plugins {
alias(libs.plugins.architecture.android.feature)
// For @Serializable type-safe navigation routes.
alias(libs.plugins.architecture.kotlinx.serialization)
}
android {

View File

@@ -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 = {},
)
}
}

View File

@@ -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<CharacterListRoute> {
CharacterListRoot(onCharacterClick = onCharacterClick)
}
}

View File

@@ -0,0 +1,8 @@
<resources>
<string name="characters_title">Characters</string>
<string name="characters_empty">No characters to show.</string>
<string name="cd_character_avatar">Avatar of %1$s</string>
<string name="status_alive">Alive</string>
<string name="status_dead">Dead</string>
<string name="status_unknown">Unknown</string>
</resources>

View File

@@ -17,4 +17,8 @@ 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).
// `api` because CharacterListState.characters exposes ImmutableList in the public state API.
api(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,151 @@
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) {
// Flip the flag synchronously so a guard reading state sees it immediately.
_state.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
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++
}
// 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 {
it.copy(
characters = accumulated.toImmutableList(),
isLoading = false,
currentPage = loadedPage,
endReached = endReached,
// The list is shown; the snackbar already surfaced any partial failure.
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) {
// 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 {
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,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)
}

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" }