Merge pull request #3 from AdrianKuta/feat/breadth-contrast

Breadth & Contrast (REDI-90…93): detail screen, MVVM about, Views renderer, Compose↔View interop
This commit is contained in:
2026-06-10 14:14:46 +02:00
committed by GitHub
33 changed files with 1229 additions and 33 deletions

View File

@@ -6,19 +6,21 @@ be minimal but complete and idiomatic.
> **Status:** built milestone-by-milestone from the
> [Linear backlog](https://linear.app/adrian-kuta/project/android-architecture-showcase-b5ecdeddda6c).
> **Foundation** (scaffold, version catalog, `build-logic` convention plugins) is complete and the
> project assembles green. Full architecture docs land with the *Quality & Docs* milestone.
> **Foundation**, **Core Infrastructure**, the **Flagship MVI** characters feature, and
> **Breadth & Contrast** (character detail, the MVVM About screen, the Views renderer, and
> Compose↔View interop) are complete and the project assembles green. Full architecture docs land
> with the *Quality & Docs* milestone.
## Stack
Multi-module Gradle + `build-logic` convention plugins · Koin (constructor DSL) · Ktor ·
KotlinX Serialization · Coil · Kermit · type-safe Compose Navigation. Data comes from the no-key
KotlinX Serialization · Coil · Timber · type-safe Compose Navigation. Data comes from the no-key
[Rick & Morty API](https://rickandmortyapi.com/).
What it will showcase: **MVI** as the primary presentation pattern (flagship *characters* feature),
an **MVVM** contrast screen, and the same MVI `ViewModel` driven by **two renderers** Jetpack
Compose and classic **XML + ViewBinding + RecyclerView** — proving the presentation logic is
UI-toolkit-agnostic.
What it showcases: **MVI** as the primary presentation pattern (flagship *characters* feature),
an **MVVM** contrast screen (*about*), and the same MVI `ViewModel` driven by **two renderers**
Jetpack Compose and classic **XML + ViewBinding + RecyclerView** — proving the presentation logic is
UI-toolkit-agnostic. See [Presentation patterns](#presentation-patterns-mvi-vs-mvvm) below.
## Module structure
@@ -40,6 +42,36 @@ UI-toolkit-agnostic.
**Dependency rules:** `presentation → domain ← data`; `domain` depends only on `:core:domain`;
features never depend on other features; `:app` wires the graph.
## Presentation patterns (MVI vs MVVM)
Both patterns live side by side so the trade-off is concrete, not theoretical.
| | **MVI** (`:feature:characters:*`) | **MVVM** (`:feature:about:presentation`) |
|---|---|---|
| State | one immutable `State` data class | one immutable `State` data class |
| User input | a single `onAction(Action)` funnel + sealed `Action` | plain public methods on the `ViewModel` |
| Side effects | one-time `Event`s via a `Channel` (nav, snackbar) | none — the screen calls a method / uses `LocalUriHandler` directly |
| Best when | state is complex and interacting; effects matter | the screen is small and mostly static |
The flagship characters list is MVI because its state is genuinely complex — pagination, loading
vs. next-page loading, error surfacing, and `SavedStateHandle` restore after process death — and it
emits navigation/snackbar effects. The About screen is deliberately MVVM: a `StateFlow` plus a couple
of public methods, with **no `Action` and no `Event` types at all**, because that ceremony would be
pure overhead for static content. Rule of thumb: **reach for MVI when state is complex and side
effects matter; reach for MVVM when the screen is simple.**
### One ViewModel, two renderers
`:feature:characters:presentation` is **UI-toolkit-agnostic** — it has no Compose *and* no Views
dependency (state stays Compose-stable via `kotlinx-collections-immutable` rather than `@Stable`).
The exact same `CharacterListViewModel` (State/Action/Event/UI-model) is rendered twice:
- `:feature:characters:presentation-compose` — Jetpack Compose (`LazyColumn`).
- `:feature:characters:presentation-views``Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`.
`:app` hosts the Views renderer inside the Compose `NavHost` via `AndroidFragment` (Compose↔View
interop) and injects all navigation as callbacks, so the renderers stay decoupled from each other.
## Build & run
```bash

View File

@@ -2,6 +2,8 @@ plugins {
alias(libs.plugins.architecture.android.application)
alias(libs.plugins.architecture.compose)
alias(libs.plugins.architecture.koin)
// For the @Serializable CharactersViewsRoute (Compose↔View interop destination).
alias(libs.plugins.architecture.kotlinx.serialization)
}
android {
@@ -16,16 +18,23 @@ dependencies {
implementation(project(":core:data"))
implementation(project(":core:design-system"))
// Characters feature: data + presentation (Koin modules) + Compose renderer (nav graph).
// Characters feature: data + presentation (Koin modules) + both renderers (Compose nav graph,
// Views Fragment hosted via interop).
implementation(project(":feature:characters:data"))
implementation(project(":feature:characters:presentation"))
implementation(project(":feature:characters:presentation-compose"))
implementation(project(":feature:characters:presentation-views"))
// About feature (MVVM contrast).
implementation(project(":feature:about:presentation"))
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)
// Compose↔View interop: hosts a Fragment inside the Compose NavHost.
implementation(libs.androidx.fragment.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,6 +2,7 @@ package com.example.architecture
import android.app.Application
import com.example.architecture.core.data.di.coreDataModule
import com.example.architecture.feature.about.presentation.di.aboutPresentationModule
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
@@ -31,6 +32,8 @@ class ArchitectureApp : Application() {
// characters feature
charactersDataModule,
charactersPresentationModule,
// about feature (MVVM contrast)
aboutPresentationModule,
)
}
}

View File

@@ -0,0 +1,11 @@
package com.example.architecture
import kotlinx.serialization.Serializable
/**
* Route for the characters list rendered with the classic **Views** toolkit. It lives in `:app`
* because `:app` owns Compose↔View interop — the `:feature:characters:presentation-views` module
* stays navigation-agnostic (it knows nothing about Compose Navigation or this route).
*/
@Serializable
data object CharactersViewsRoute

View File

@@ -1,16 +1,30 @@
package com.example.architecture
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.fragment.app.FragmentActivity
import androidx.fragment.compose.AndroidFragment
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.architecture.core.design.system.theme.AppTheme
import com.example.architecture.feature.about.presentation.AboutRoute
import com.example.architecture.feature.about.presentation.aboutGraph
import com.example.architecture.feature.characters.presentation.compose.CharacterDetailRoute
import com.example.architecture.feature.characters.presentation.compose.CharacterListRoute
import com.example.architecture.feature.characters.presentation.compose.charactersGraph
import com.example.architecture.feature.characters.presentation.views.CharacterListFragment
class MainActivity : ComponentActivity() {
/**
* Hosts the single Compose NavHost and owns every cross-feature / cross-toolkit wiring:
* - the characters graph (Compose list + detail),
* - the About graph (MVVM contrast),
* - the Views renderer embedded via [AndroidFragment] (Compose↔View interop).
*
* Extends [FragmentActivity] (not plain ComponentActivity) so [AndroidFragment] has a FragmentManager.
*/
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -22,8 +36,23 @@ class MainActivity : ComponentActivity() {
startDestination = CharacterListRoute,
) {
charactersGraph(
onCharacterClick = { /* Detail navigation is wired in the next milestone. */ },
navController = navController,
onOpenAbout = { navController.navigate(AboutRoute) },
onOpenViewsList = { navController.navigate(CharactersViewsRoute) },
)
aboutGraph(
onNavigateBack = { navController.popBackStack() },
)
// Compose↔View interop: the same characters list, rendered by a Fragment. :app
// injects the navigation callbacks so the Views module stays nav-agnostic.
composable<CharactersViewsRoute> {
AndroidFragment<CharacterListFragment> { fragment ->
fragment.onCharacterClick = { id ->
navController.navigate(CharacterDetailRoute(id))
}
fragment.onNavigateBack = { navController.popBackStack() }
}
}
}
}
}

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)
}
// MVVM contrast screen (StateFlow + plain VM methods, no Action/Event funnel). Static content,

View File

@@ -0,0 +1,21 @@
package com.example.architecture.feature.about.presentation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import kotlinx.serialization.Serializable
/** Type-safe route for the About screen. */
@Serializable
data object AboutRoute
/**
* The About feature nav graph. It only needs a "go back" callback — `:app` wires it to the shared
* NavController, keeping this feature decoupled from how it is reached.
*/
fun NavGraphBuilder.aboutGraph(
onNavigateBack: () -> Unit,
) {
composable<AboutRoute> {
AboutRoot(onNavigateBack = onNavigateBack)
}
}

View File

@@ -0,0 +1,140 @@
package com.example.architecture.feature.about.presentation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
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.theme.AppTheme
import com.example.architecture.feature.about.presentation.model.AboutLink
/**
* Root for the MVVM About screen. Note how different the wiring is from an MVI Root: it collects
* [AboutState] and passes the ViewModel's **method reference** straight through — there is no
* `onAction` funnel and no event observation, because this screen has neither.
*/
@Composable
fun AboutRoot(
onNavigateBack: () -> Unit,
viewModel: AboutViewModel = org.koin.androidx.compose.koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
AboutScreen(
state = state,
onToggleMvvmNote = viewModel::onToggleMvvmNote,
onNavigateBack = onNavigateBack,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(
state: AboutState,
onToggleMvvmNote: () -> Unit,
onNavigateBack: () -> Unit,
) {
val uriHandler = LocalUriHandler.current
AppScaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.about_title)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.cd_back),
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(text = state.appName, style = MaterialTheme.typography.headlineSmall)
Text(text = state.description, style = MaterialTheme.typography.bodyLarge)
Text(
text = stringResource(R.string.about_architecture_header),
style = MaterialTheme.typography.titleMedium,
)
state.architectureHighlights.forEach { highlight ->
Text(text = "$highlight", style = MaterialTheme.typography.bodyMedium)
}
// The expandable card is driven entirely by the VM's plain method — the MVVM contrast.
AppCard(
onClick = onToggleMvvmNote,
header = {
Text(
text = stringResource(R.string.about_mvvm_header),
style = MaterialTheme.typography.titleMedium,
)
},
) {
Text(
text = if (state.showMvvmNote) state.mvvmNote else stringResource(R.string.about_mvvm_hint),
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
text = stringResource(R.string.about_links_header),
style = MaterialTheme.typography.titleMedium,
)
state.links.forEach { link ->
TextButton(onClick = { uriHandler.openUri(link.url) }) {
Text(text = link.label)
}
}
}
}
}
@Preview
@Composable
private fun AboutScreenPreview() {
AppTheme {
AboutScreen(
state = AboutState(
appName = "Android Architecture Showcase",
description = "A reference Android app demonstrating a modern multi-module architecture.",
architectureHighlights = listOf(
"Multi-module Clean Architecture.",
"MVI primary, MVVM contrast.",
),
mvvmNote = "MVI funnels intents through onAction; this screen uses plain VM methods.",
showMvvmNote = true,
links = listOf(AboutLink("GitHub repository", "https://example.com")),
),
onToggleMvvmNote = {},
onNavigateBack = {},
)
}
}

View File

@@ -0,0 +1,22 @@
package com.example.architecture.feature.about.presentation
import androidx.compose.runtime.Stable
import com.example.architecture.feature.about.presentation.model.AboutLink
/**
* State for the MVVM About screen.
*
* Contrast with the MVI [com.example.architecture.feature.characters.presentation.CharacterListState]:
* that one is UI-agnostic and stays Compose-free by using `ImmutableList`. This module is a
* Compose-only presentation layer, so it simply annotates the state `@Stable` (cheaper than pulling
* in kotlinx-collections-immutable) to keep the `List` fields from defeating recomposition skipping.
*/
@Stable
data class AboutState(
val appName: String = "",
val description: String = "",
val architectureHighlights: List<String> = emptyList(),
val mvvmNote: String = "",
val showMvvmNote: Boolean = false,
val links: List<AboutLink> = emptyList(),
)

View File

@@ -0,0 +1,63 @@
package com.example.architecture.feature.about.presentation
import androidx.lifecycle.ViewModel
import com.example.architecture.feature.about.presentation.model.AboutLink
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* **MVVM — the deliberate contrast to the app's MVI screens.**
*
* There is no `Action` sealed type and no `Event`/effect `Channel`. The screen reads [state] and
* invokes the ViewModel's **plain public methods** directly. That is the whole point of this screen:
* for small, mostly-static UI, the MVI ceremony (single `onAction` funnel + one-time event channel)
* isn't worth it. See [AboutState] for the matching stability note, and the in-app "Why is this
* screen MVVM?" card / the README for when to pick each pattern.
*
* The showcase copy lives here as state (rather than in string resources) precisely to demonstrate
* the "StateFlow holds the content" MVVM shape; real localizable product copy would use resources.
*/
class AboutViewModel : ViewModel() {
private val _state = MutableStateFlow(
AboutState(
appName = "Android Architecture Showcase",
description = "A reference Android app that demonstrates a modern, multi-module " +
"architecture: feature-layered Clean Architecture, a typed networking + error stack, " +
"and a single presentation layer rendered by two different UI toolkits.",
architectureHighlights = listOf(
"Multi-module, feature-layered Clean Architecture (presentation → domain ← data).",
"Gradle convention plugins with a single version catalog as the source of truth.",
"MVI is the primary pattern; this About screen is the MVVM contrast.",
"One UI-agnostic ViewModel rendered by both Jetpack Compose and classic Android Views.",
"Koin for DI, Ktor for networking, type-safe Compose Navigation, Coil for images.",
"Typed Result / DataError handling surfaced to the UI as UiText.",
),
mvvmNote = "MVI funnels every user intent through a single onAction(Action) entry point " +
"and emits one-time effects (navigation, snackbars) through an Event channel. That " +
"structure pays off when state is complex and interacting — like the paginated, " +
"process-death-restorable characters list. This screen is intentionally MVVM instead: " +
"the ViewModel exposes a StateFlow plus plain public methods (onToggleMvvmNote), with " +
"no Action or Event types at all. Rule of thumb: reach for MVI when state is complex " +
"and side effects matter; reach for MVVM when the screen is small and mostly static.",
links = listOf(
AboutLink(
label = "GitHub repository",
url = "https://github.com/AdrianKuta/android-architecture-showcase",
),
AboutLink(
label = "Rick & Morty API (data source)",
url = "https://rickandmortyapi.com",
),
),
),
)
val state: StateFlow<AboutState> = _state.asStateFlow()
/** MVVM: a plain public method mutates state directly — no Action object, no reducer funnel. */
fun onToggleMvvmNote() {
_state.update { it.copy(showMvvmNote = !it.showMvvmNote) }
}
}

View File

@@ -0,0 +1,10 @@
package com.example.architecture.feature.about.presentation.di
import com.example.architecture.feature.about.presentation.AboutViewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
/** Presentation DI for the About feature. */
val aboutPresentationModule = module {
viewModelOf(::AboutViewModel)
}

View File

@@ -0,0 +1,7 @@
package com.example.architecture.feature.about.presentation.model
/** A labelled external link shown on the About screen. */
data class AboutLink(
val label: String,
val url: String,
)

View File

@@ -0,0 +1,8 @@
<resources>
<string name="about_title">About</string>
<string name="about_architecture_header">Architecture highlights</string>
<string name="about_mvvm_header">Why is this screen MVVM?</string>
<string name="about_mvvm_hint">Tap to see how this MVVM screen differs from the app\'s MVI screens.</string>
<string name="about_links_header">Links</string>
<string name="cd_back">Back</string>
</resources>

View File

@@ -0,0 +1,227 @@
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
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.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.CharacterDetailAction
import com.example.architecture.feature.characters.presentation.CharacterDetailEvent
import com.example.architecture.feature.characters.presentation.CharacterDetailState
import com.example.architecture.feature.characters.presentation.CharacterDetailViewModel
import com.example.architecture.feature.characters.presentation.model.CharacterDetailUi
import org.koin.androidx.compose.koinViewModel
/**
* Root: owns the detail ViewModel (Koin supplies it the route's `characterId` via SavedStateHandle),
* observes the one-time [CharacterDetailEvent.NavigateBack], and forwards "go back" up the nav stack.
*/
@Composable
fun CharacterDetailRoot(
onNavigateBack: () -> Unit,
viewModel: CharacterDetailViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
ObserveAsEvents(viewModel.events) { event ->
when (event) {
CharacterDetailEvent.NavigateBack -> onNavigateBack()
}
}
CharacterDetailScreen(state = state, onAction = viewModel::onAction)
}
/** Pure, stateless screen — previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CharacterDetailScreen(
state: CharacterDetailState,
onAction: (CharacterDetailAction) -> Unit,
) {
AppScaffold(
topBar = {
TopAppBar(
title = {
Text(state.details?.name ?: stringResource(R.string.character_detail_title))
},
navigationIcon = {
IconButton(onClick = { onAction(CharacterDetailAction.OnBackClick) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.cd_back),
)
}
},
)
},
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
val error = state.error
val details = state.details
when {
state.isLoading -> LoadingIndicator()
// Error wins over any (now-cleared) details so a failed load can't show stale content.
error != null -> ErrorState(
message = error.asString(),
onRetry = { onAction(CharacterDetailAction.OnRetry) },
)
details != null -> CharacterDetailContent(details)
}
}
}
}
@Composable
private fun CharacterDetailContent(details: CharacterDetailUi) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
NetworkImage(
imageUrl = details.imageUrl,
contentDescription = stringResource(R.string.cd_character_image, details.name),
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
)
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(text = details.name, style = MaterialTheme.typography.headlineSmall)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(10.dp)
.background(details.status.indicatorColor(), CircleShape),
)
Text(
text = stringResource(details.status.labelRes()) + " · " + details.species,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HorizontalDivider()
AttributeRow(label = stringResource(R.string.detail_type), value = details.type)
AttributeRow(label = stringResource(R.string.detail_gender), value = details.gender)
AttributeRow(label = stringResource(R.string.detail_origin), value = details.origin)
AttributeRow(label = stringResource(R.string.detail_location), value = details.location)
AttributeRow(
label = stringResource(R.string.detail_episodes),
value = details.episodeCount.toString(),
)
}
}
}
@Composable
private fun AttributeRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.End,
)
}
}
private val previewDetails = CharacterDetailUi(
id = 1,
name = "Rick Sanchez",
status = CharacterStatus.ALIVE,
species = "Human",
type = "",
gender = "Male",
origin = "Earth (C-137)",
location = "Citadel of Ricks",
imageUrl = "",
episodeCount = 51,
)
@Preview
@Composable
private fun CharacterDetailScreenLoadedPreview() {
AppTheme {
CharacterDetailScreen(state = CharacterDetailState(details = previewDetails), onAction = {})
}
}
@Preview
@Composable
private fun CharacterDetailScreenLoadingPreview() {
AppTheme {
CharacterDetailScreen(state = CharacterDetailState(isLoading = true), onAction = {})
}
}
@Preview
@Composable
private fun CharacterDetailScreenErrorPreview() {
AppTheme {
CharacterDetailScreen(
state = CharacterDetailState(
error = com.example.architecture.core.presentation.UiText.DynamicString(
"Failed to load character details.",
),
),
onAction = {},
)
}
}

View File

@@ -3,8 +3,8 @@ 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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -14,8 +14,14 @@ 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.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@@ -24,13 +30,14 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.setValue
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
@@ -58,10 +65,15 @@ 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.
*
* [onOpenAbout] and [onOpenViewsList] are renderer-only chrome (a Compose overflow menu), so they
* are plain callbacks rather than going through the shared, UI-agnostic ViewModel.
*/
@Composable
fun CharacterListRoot(
onCharacterClick: (Int) -> Unit,
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
viewModel: CharacterListViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -81,6 +93,8 @@ fun CharacterListRoot(
CharacterListScreen(
state = state,
onAction = viewModel::onAction,
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
snackbarHostState = snackbarHostState,
)
}
@@ -91,10 +105,17 @@ fun CharacterListRoot(
fun CharacterListScreen(
state: CharacterListState,
onAction: (CharacterListAction) -> Unit,
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
AppScaffold(
topBar = { TopAppBar(title = { Text(stringResource(R.string.characters_title)) }) },
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.characters_title)) },
actions = { CharacterListOverflowMenu(onOpenAbout = onOpenAbout, onOpenViewsList = onOpenViewsList) },
)
},
) { innerPadding ->
Box(
modifier = Modifier
@@ -124,6 +145,36 @@ fun CharacterListScreen(
}
}
@Composable
private fun CharacterListOverflowMenu(
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.cd_more_options),
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_open_as_views)) },
onClick = {
expanded = false
onOpenViewsList()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_about)) },
onClick = {
expanded = false
onOpenAbout()
},
)
}
}
@Composable
private fun CharacterList(
state: CharacterListState,
@@ -148,7 +199,7 @@ private fun CharacterList(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items = state.characters, key = { it.id }) { character ->
@@ -218,18 +269,6 @@ private fun EmptyState() {
}
}
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),
@@ -243,6 +282,8 @@ private fun CharacterListScreenLoadedPreview() {
CharacterListScreen(
state = CharacterListState(characters = previewCharacters),
onAction = {},
onOpenAbout = {},
onOpenViewsList = {},
)
}
}
@@ -258,6 +299,8 @@ private fun CharacterListScreenErrorPreview() {
),
),
onAction = {},
onOpenAbout = {},
onOpenViewsList = {},
)
}
}

View File

@@ -0,0 +1,19 @@
package com.example.architecture.feature.characters.presentation.compose
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.Color
import com.example.architecture.feature.characters.domain.model.CharacterStatus
/** Shared Compose presentation helpers for [CharacterStatus], used by both the list and detail screens. */
@StringRes
internal fun CharacterStatus.labelRes(): Int = when (this) {
CharacterStatus.ALIVE -> R.string.status_alive
CharacterStatus.DEAD -> R.string.status_dead
CharacterStatus.UNKNOWN -> R.string.status_unknown
}
internal fun CharacterStatus.indicatorColor(): Color = when (this) {
CharacterStatus.ALIVE -> Color(0xFF4CAF50)
CharacterStatus.DEAD -> Color(0xFFE53935)
CharacterStatus.UNKNOWN -> Color(0xFF9E9E9E)
}

View File

@@ -1,5 +1,6 @@
package com.example.architecture.feature.characters.presentation.compose
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import kotlinx.serialization.Serializable
@@ -8,14 +9,33 @@ import kotlinx.serialization.Serializable
@Serializable
data object CharacterListRoute
/** Type-safe route for the character detail screen — carries only the typed id, never an object. */
@Serializable
data class CharacterDetailRoute(val characterId: Int)
/**
* 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.
* The characters feature nav graph. List→detail is intra-feature navigation, so it is driven by the
* [navController] passed in. Cross-boundary destinations (the About screen, the Views renderer hosted
* by `:app`) stay decoupled as callbacks supplied by `:app`.
*/
fun NavGraphBuilder.charactersGraph(
onCharacterClick: (Int) -> Unit,
navController: NavController,
onOpenAbout: () -> Unit,
onOpenViewsList: () -> Unit,
) {
composable<CharacterListRoute> {
CharacterListRoot(onCharacterClick = onCharacterClick)
CharacterListRoot(
onCharacterClick = { characterId ->
navController.navigate(CharacterDetailRoute(characterId))
},
onOpenAbout = onOpenAbout,
onOpenViewsList = onOpenViewsList,
)
}
composable<CharacterDetailRoute> {
// The typed CharacterDetailRoute serializes `characterId` into the destination's arguments,
// which Navigation copies into the ViewModel's SavedStateHandle — that is where
// CharacterDetailViewModel reads it (keeping that module free of any navigation dependency).
CharacterDetailRoot(onNavigateBack = { navController.popBackStack() })
}
}

View File

@@ -5,4 +5,19 @@
<string name="status_alive">Alive</string>
<string name="status_dead">Dead</string>
<string name="status_unknown">Unknown</string>
<!-- Overflow menu -->
<string name="cd_more_options">More options</string>
<string name="menu_about">About</string>
<string name="menu_open_as_views">Open as Views</string>
<!-- Detail screen -->
<string name="character_detail_title">Character</string>
<string name="cd_back">Back</string>
<string name="cd_character_image">Image of %1$s</string>
<string name="detail_type">Type</string>
<string name="detail_gender">Gender</string>
<string name="detail_origin">Origin</string>
<string name="detail_location">Location</string>
<string name="detail_episodes">Episodes</string>
</resources>

View File

@@ -0,0 +1,74 @@
package com.example.architecture.feature.characters.presentation.views
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil3.load
import coil3.request.crossfade
import coil3.request.transformations
import coil3.transform.CircleCropTransformation
import com.example.architecture.feature.characters.presentation.model.CharacterUi
import com.example.architecture.feature.characters.presentation.views.databinding.ItemCharacterBinding
/**
* RecyclerView adapter over the SAME [CharacterUi] presentation model the Compose renderer uses.
* [DiffUtil] computes minimal updates; Coil loads avatars straight into the `ImageView`.
*/
internal class CharacterListAdapter(
private val onItemClick: (Int) -> Unit,
) : ListAdapter<CharacterUi, CharacterListAdapter.CharacterViewHolder>(DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CharacterViewHolder {
val binding = ItemCharacterBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return CharacterViewHolder(binding)
}
override fun onBindViewHolder(holder: CharacterViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class CharacterViewHolder(
private val binding: ItemCharacterBinding,
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.root.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
onItemClick(getItem(position).id)
}
}
}
fun bind(item: CharacterUi) {
val context = binding.root.context
binding.name.text = item.name
binding.statusSpecies.text =
context.getString(item.status.labelRes()) + " · " + item.species
ViewCompat.setBackgroundTintList(
binding.statusDot,
ColorStateList.valueOf(item.status.indicatorColor()),
)
binding.avatar.contentDescription =
context.getString(R.string.cd_character_avatar, item.name)
binding.avatar.load(item.imageUrl) {
crossfade(true)
transformations(CircleCropTransformation())
}
}
}
private companion object {
val DIFF_CALLBACK = object : DiffUtil.ItemCallback<CharacterUi>() {
override fun areItemsTheSame(oldItem: CharacterUi, newItem: CharacterUi): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: CharacterUi, newItem: CharacterUi): Boolean =
oldItem == newItem
}
}
}

View File

@@ -0,0 +1,127 @@
package com.example.architecture.feature.characters.presentation.views
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.architecture.core.presentation.asString
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.views.databinding.FragmentCharacterListBinding
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* Classic Views renderer for the characters list. It drives the **same** [CharacterListViewModel] as
* the Compose screen — proving the presentation logic (State/Action/Event/UI-model) is truly
* UI-agnostic. Koin's `by viewModel()` supplies the VM (and its `SavedStateHandle`).
*
* `:app` (the interop owner) wires [onCharacterClick] / [onNavigateBack]; the Fragment never touches
* the Compose NavController, so this module stays decoupled from navigation.
*/
class CharacterListFragment : Fragment() {
var onCharacterClick: (Int) -> Unit = {}
var onNavigateBack: () -> Unit = {}
private var _binding: FragmentCharacterListBinding? = null
private val binding get() = _binding!!
private val viewModel: CharacterListViewModel by viewModel()
private lateinit var listAdapter: CharacterListAdapter
private var pagingListener: RecyclerView.OnScrollListener? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentCharacterListBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
listAdapter = CharacterListAdapter(
onItemClick = { id -> viewModel.onAction(CharacterListAction.OnCharacterClick(id)) },
)
val scrollListener = pagingScrollListener()
pagingListener = scrollListener
binding.recyclerView.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = listAdapter
addOnScrollListener(scrollListener)
}
binding.toolbar.setNavigationOnClickListener { onNavigateBack() }
binding.retryButton.setOnClickListener {
viewModel.onAction(CharacterListAction.OnRetry)
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { viewModel.state.collect(::render) }
launch { viewModel.events.collect(::handleEvent) }
}
}
}
private fun render(state: CharacterListState) {
listAdapter.submitList(state.characters)
val showFullScreenError = state.error != null && state.characters.isEmpty()
binding.progressBar.isVisible = state.isLoading
binding.nextPageProgress.isVisible = state.isLoadingNextPage
binding.errorContainer.isVisible = showFullScreenError
binding.recyclerView.isVisible = !state.isLoading && !showFullScreenError
if (showFullScreenError) {
binding.errorMessage.text = state.error?.asString(requireContext())
}
}
private fun handleEvent(event: CharacterListEvent) {
when (event) {
is CharacterListEvent.NavigateToDetail -> onCharacterClick(event.characterId)
is CharacterListEvent.ShowSnackbar -> Snackbar.make(
binding.root,
event.message.asString(requireContext()),
Snackbar.LENGTH_SHORT,
).show()
}
}
private fun pagingScrollListener() = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return
val lastVisible = layoutManager.findLastVisibleItemPosition()
// `lastVisible >= 0` skips the empty-list case (findLastVisibleItemPosition() == -1),
// mirroring the Compose renderer's `total > 0` guard. The ViewModel still guards against
// duplicate / end-reached / already-loading requests.
if (lastVisible >= 0 && lastVisible >= layoutManager.itemCount - 1) {
viewModel.onAction(CharacterListAction.OnLoadNextPage)
}
}
}
override fun onDestroyView() {
// Remove the scroll listener and detach the adapter before nulling the binding so neither
// the RecyclerView nor this Fragment is leaked.
pagingListener?.let { binding.recyclerView.removeOnScrollListener(it) }
pagingListener = null
binding.recyclerView.adapter = null
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,24 @@
package com.example.architecture.feature.characters.presentation.views
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import com.example.architecture.feature.characters.domain.model.CharacterStatus
/**
* Views-renderer presentation helpers for [CharacterStatus]. These intentionally mirror the Compose
* renderer's helpers but return platform types (a string-res id and an ARGB Int) — each renderer
* owns its own resources, so the small label duplication across modules is expected.
*/
@StringRes
internal fun CharacterStatus.labelRes(): Int = when (this) {
CharacterStatus.ALIVE -> R.string.characters_views_status_alive
CharacterStatus.DEAD -> R.string.characters_views_status_dead
CharacterStatus.UNKNOWN -> R.string.characters_views_status_unknown
}
@ColorInt
internal fun CharacterStatus.indicatorColor(): Int = when (this) {
CharacterStatus.ALIVE -> 0xFF4CAF50.toInt()
CharacterStatus.DEAD -> 0xFFE53935.toInt()
CharacterStatus.UNKNOWN -> 0xFF9E9E9E.toInt()
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Solid white oval, tinted per status at bind time via setBackgroundTintList. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@android:color/white" />
</shape>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationContentDescription="@string/cd_back"
app:navigationIcon="@drawable/ic_arrow_back"
app:title="@string/characters_views_title" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:scrollbars="vertical" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<LinearLayout
android:id="@+id/errorContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp"
android:visibility="gone">
<TextView
android:id="@+id/errorMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<Button
android:id="@+id/retryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/characters_views_retry" />
</LinearLayout>
<ProgressBar
android:id="@+id/nextPageProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="16dp"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="4dp"
android:paddingVertical="12dp">
<ImageView
android:id="@+id/avatar"
android:layout_width="64dp"
android:layout_height="64dp"
android:scaleType="centerCrop" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<View
android:id="@+id/statusDot"
android:layout_width="8dp"
android:layout_height="8dp"
android:background="@drawable/bg_status_dot" />
<TextView
android:id="@+id/statusSpecies"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,9 @@
<resources>
<string name="characters_views_title">Characters (Views)</string>
<string name="characters_views_retry">Retry</string>
<string name="cd_back">Back</string>
<string name="cd_character_avatar">Avatar of %1$s</string>
<string name="characters_views_status_alive">Alive</string>
<string name="characters_views_status_dead">Dead</string>
<string name="characters_views_status_unknown">Unknown</string>
</resources>

View File

@@ -0,0 +1,6 @@
package com.example.architecture.feature.characters.presentation
sealed interface CharacterDetailAction {
data object OnRetry : CharacterDetailAction
data object OnBackClick : CharacterDetailAction
}

View File

@@ -0,0 +1,6 @@
package com.example.architecture.feature.characters.presentation
sealed interface CharacterDetailEvent {
/** One-time effect: the user asked to leave; the renderer pops the back stack. */
data object NavigateBack : CharacterDetailEvent
}

View File

@@ -0,0 +1,14 @@
package com.example.architecture.feature.characters.presentation
import com.example.architecture.core.presentation.UiText
import com.example.architecture.feature.characters.presentation.model.CharacterDetailUi
/**
* UI state for the character detail screen. Like [CharacterListState] this is Compose-free: all
* fields are stable types, so no `@Stable` (and therefore no Compose dependency) is needed.
*/
data class CharacterDetailState(
val details: CharacterDetailUi? = null,
val isLoading: Boolean = false,
val error: UiText? = null,
)

View File

@@ -0,0 +1,73 @@
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.onFailure
import com.example.architecture.core.domain.onSuccess
import com.example.architecture.core.presentation.toUiText
import com.example.architecture.feature.characters.domain.CharacterRepository
import com.example.architecture.feature.characters.presentation.model.toCharacterDetailUi
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 character detail screen.
*
* Type-safe navigation writes the route's typed `characterId` into [SavedStateHandle] under its
* field name. Reading that raw key — instead of `savedStateHandle.toRoute<CharacterDetailRoute>()` —
* is deliberate: it keeps this module free of any navigation/Compose dependency (the route type
* lives in the renderer). The renderer is what reads the route via `toRoute()`.
*/
class CharacterDetailViewModel(
savedStateHandle: SavedStateHandle,
private val characterRepository: CharacterRepository,
) : ViewModel() {
private val characterId: Int = checkNotNull(savedStateHandle.get<Int>(KEY_CHARACTER_ID)) {
"CharacterDetailRoute.characterId missing from SavedStateHandle"
}
private val _state = MutableStateFlow(CharacterDetailState())
val state = _state.asStateFlow()
private val _events = Channel<CharacterDetailEvent>()
val events = _events.receiveAsFlow()
init {
loadDetails()
}
fun onAction(action: CharacterDetailAction) {
when (action) {
CharacterDetailAction.OnRetry -> loadDetails()
CharacterDetailAction.OnBackClick -> viewModelScope.launch {
_events.send(CharacterDetailEvent.NavigateBack)
}
}
}
private fun loadDetails() {
// Clear previous details too: a (re)load must never leave stale content beside a fresh error.
_state.update { it.copy(isLoading = true, error = null, details = null) }
viewModelScope.launch {
characterRepository.getCharacterDetails(characterId)
.onSuccess { details ->
_state.update {
it.copy(details = details.toCharacterDetailUi(), isLoading = false, error = null)
}
}
.onFailure { failure ->
_state.update { it.copy(isLoading = false, error = failure.toUiText()) }
}
}
}
private companion object {
const val KEY_CHARACTER_ID = "characterId"
}
}

View File

@@ -1,10 +1,12 @@
package com.example.architecture.feature.characters.presentation.di
import com.example.architecture.feature.characters.presentation.CharacterDetailViewModel
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. */
/** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModels it provides. */
val charactersPresentationModule = module {
viewModelOf(::CharacterListViewModel)
viewModelOf(::CharacterDetailViewModel)
}

View File

@@ -0,0 +1,36 @@
package com.example.architecture.feature.characters.presentation.model
import com.example.architecture.feature.characters.domain.model.CharacterDetails
import com.example.architecture.feature.characters.domain.model.CharacterStatus
/**
* Presentation model for the character detail screen. Blank free-text API fields (notably `type`)
* are pre-formatted to an em dash here so the renderer stays dumb.
*/
data class CharacterDetailUi(
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,
)
fun CharacterDetails.toCharacterDetailUi(): CharacterDetailUi = CharacterDetailUi(
id = id,
name = name,
status = status,
species = species.ifBlank { DASH },
type = type.ifBlank { DASH },
gender = gender.ifBlank { DASH },
origin = origin.ifBlank { DASH },
location = location.ifBlank { DASH },
imageUrl = imageUrl,
episodeCount = episodeCount,
)
private const val DASH = ""

View File

@@ -81,6 +81,7 @@ androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
# --- Coroutines / serialization ---
@@ -133,6 +134,7 @@ compose = [
"androidx-compose-foundation",
"androidx-compose-ui-tooling-preview",
"androidx-compose-material3",
"androidx-compose-material-icons-core",
]
koin = ["koin-core", "koin-android"]
ktor = [