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/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/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/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()
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?,
+)
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/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/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-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 a6ff842..e8e8a28 100644
--- a/feature/characters/presentation/build.gradle.kts
+++ b/feature/characters/presentation/build.gradle.kts
@@ -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)
}
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..c131025
--- /dev/null
+++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModel.kt
@@ -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()
+ 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()
+ 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"
+ }
+}
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)
+}
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" }