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:
46
README.md
46
README.md
@@ -6,19 +6,21 @@ be minimal but complete and idiomatic.
|
|||||||
|
|
||||||
> **Status:** built milestone-by-milestone from the
|
> **Status:** built milestone-by-milestone from the
|
||||||
> [Linear backlog](https://linear.app/adrian-kuta/project/android-architecture-showcase-b5ecdeddda6c).
|
> [Linear backlog](https://linear.app/adrian-kuta/project/android-architecture-showcase-b5ecdeddda6c).
|
||||||
> **Foundation** (scaffold, version catalog, `build-logic` convention plugins) is complete and the
|
> **Foundation**, **Core Infrastructure**, the **Flagship MVI** characters feature, and
|
||||||
> project assembles green. Full architecture docs land with the *Quality & Docs* milestone.
|
> **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
|
## Stack
|
||||||
|
|
||||||
Multi-module Gradle + `build-logic` convention plugins · Koin (constructor DSL) · Ktor ·
|
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/).
|
[Rick & Morty API](https://rickandmortyapi.com/).
|
||||||
|
|
||||||
What it will showcase: **MVI** as the primary presentation pattern (flagship *characters* feature),
|
What it showcases: **MVI** as the primary presentation pattern (flagship *characters* feature),
|
||||||
an **MVVM** contrast screen, and the same MVI `ViewModel` driven by **two renderers** — Jetpack
|
an **MVVM** contrast screen (*about*), and the same MVI `ViewModel` driven by **two renderers** —
|
||||||
Compose and classic **XML + ViewBinding + RecyclerView** — proving the presentation logic is
|
Jetpack Compose and classic **XML + ViewBinding + RecyclerView** — proving the presentation logic is
|
||||||
UI-toolkit-agnostic.
|
UI-toolkit-agnostic. See [Presentation patterns](#presentation-patterns-mvi-vs-mvvm) below.
|
||||||
|
|
||||||
## Module structure
|
## Module structure
|
||||||
|
|
||||||
@@ -40,6 +42,36 @@ UI-toolkit-agnostic.
|
|||||||
**Dependency rules:** `presentation → domain ← data`; `domain` depends only on `:core:domain`;
|
**Dependency rules:** `presentation → domain ← data`; `domain` depends only on `:core:domain`;
|
||||||
features never depend on other features; `:app` wires the graph.
|
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
|
## Build & run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ plugins {
|
|||||||
alias(libs.plugins.architecture.android.application)
|
alias(libs.plugins.architecture.android.application)
|
||||||
alias(libs.plugins.architecture.compose)
|
alias(libs.plugins.architecture.compose)
|
||||||
alias(libs.plugins.architecture.koin)
|
alias(libs.plugins.architecture.koin)
|
||||||
|
// For the @Serializable CharactersViewsRoute (Compose↔View interop destination).
|
||||||
|
alias(libs.plugins.architecture.kotlinx.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -16,16 +18,23 @@ dependencies {
|
|||||||
implementation(project(":core:data"))
|
implementation(project(":core:data"))
|
||||||
implementation(project(":core:design-system"))
|
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:data"))
|
||||||
implementation(project(":feature:characters:presentation"))
|
implementation(project(":feature:characters:presentation"))
|
||||||
implementation(project(":feature:characters:presentation-compose"))
|
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.core.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.bundles.lifecycle.compose)
|
implementation(libs.bundles.lifecycle.compose)
|
||||||
implementation(libs.androidx.navigation.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.
|
// Material Components — required for the Material3 XML Activity theme.
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
// Logging — the DebugTree is planted here; other modules log via Timber's static API.
|
// Logging — the DebugTree is planted here; other modules log via Timber's static API.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.example.architecture
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.example.architecture.core.data.di.coreDataModule
|
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.data.di.charactersDataModule
|
||||||
import com.example.architecture.feature.characters.presentation.di.charactersPresentationModule
|
import com.example.architecture.feature.characters.presentation.di.charactersPresentationModule
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
@@ -31,6 +32,8 @@ class ArchitectureApp : Application() {
|
|||||||
// characters feature
|
// characters feature
|
||||||
charactersDataModule,
|
charactersDataModule,
|
||||||
charactersPresentationModule,
|
charactersPresentationModule,
|
||||||
|
// about feature (MVVM contrast)
|
||||||
|
aboutPresentationModule,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,16 +1,30 @@
|
|||||||
package com.example.architecture
|
package com.example.architecture
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.fragment.compose.AndroidFragment
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.example.architecture.core.design.system.theme.AppTheme
|
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.CharacterListRoute
|
||||||
import com.example.architecture.feature.characters.presentation.compose.charactersGraph
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
@@ -22,8 +36,23 @@ class MainActivity : ComponentActivity() {
|
|||||||
startDestination = CharacterListRoute,
|
startDestination = CharacterListRoute,
|
||||||
) {
|
) {
|
||||||
charactersGraph(
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.architecture.android.feature)
|
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,
|
// MVVM contrast screen (StateFlow + plain VM methods, no Action/Event funnel). Static content,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ package com.example.architecture.feature.characters.presentation.compose
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
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.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
@@ -24,13 +30,14 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.
|
* 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.
|
* 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
|
@Composable
|
||||||
fun CharacterListRoot(
|
fun CharacterListRoot(
|
||||||
onCharacterClick: (Int) -> Unit,
|
onCharacterClick: (Int) -> Unit,
|
||||||
|
onOpenAbout: () -> Unit,
|
||||||
|
onOpenViewsList: () -> Unit,
|
||||||
viewModel: CharacterListViewModel = koinViewModel(),
|
viewModel: CharacterListViewModel = koinViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -81,6 +93,8 @@ fun CharacterListRoot(
|
|||||||
CharacterListScreen(
|
CharacterListScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onAction = viewModel::onAction,
|
onAction = viewModel::onAction,
|
||||||
|
onOpenAbout = onOpenAbout,
|
||||||
|
onOpenViewsList = onOpenViewsList,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -91,10 +105,17 @@ fun CharacterListRoot(
|
|||||||
fun CharacterListScreen(
|
fun CharacterListScreen(
|
||||||
state: CharacterListState,
|
state: CharacterListState,
|
||||||
onAction: (CharacterListAction) -> Unit,
|
onAction: (CharacterListAction) -> Unit,
|
||||||
|
onOpenAbout: () -> Unit,
|
||||||
|
onOpenViewsList: () -> Unit,
|
||||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||||
) {
|
) {
|
||||||
AppScaffold(
|
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 ->
|
) { innerPadding ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
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
|
@Composable
|
||||||
private fun CharacterList(
|
private fun CharacterList(
|
||||||
state: CharacterListState,
|
state: CharacterListState,
|
||||||
@@ -148,7 +199,7 @@ private fun CharacterList(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
items(items = state.characters, key = { it.id }) { character ->
|
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(
|
private val previewCharacters = persistentListOf(
|
||||||
CharacterUi(1, "Rick Sanchez", "Human", "", CharacterStatus.ALIVE),
|
CharacterUi(1, "Rick Sanchez", "Human", "", CharacterStatus.ALIVE),
|
||||||
CharacterUi(2, "Morty Smith", "Human", "", CharacterStatus.ALIVE),
|
CharacterUi(2, "Morty Smith", "Human", "", CharacterStatus.ALIVE),
|
||||||
@@ -243,6 +282,8 @@ private fun CharacterListScreenLoadedPreview() {
|
|||||||
CharacterListScreen(
|
CharacterListScreen(
|
||||||
state = CharacterListState(characters = previewCharacters),
|
state = CharacterListState(characters = previewCharacters),
|
||||||
onAction = {},
|
onAction = {},
|
||||||
|
onOpenAbout = {},
|
||||||
|
onOpenViewsList = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,6 +299,8 @@ private fun CharacterListScreenErrorPreview() {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
onAction = {},
|
onAction = {},
|
||||||
|
onOpenAbout = {},
|
||||||
|
onOpenViewsList = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.example.architecture.feature.characters.presentation.compose
|
package com.example.architecture.feature.characters.presentation.compose
|
||||||
|
|
||||||
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.NavGraphBuilder
|
import androidx.navigation.NavGraphBuilder
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@@ -8,14 +9,33 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object CharacterListRoute
|
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
|
* The characters feature nav graph. List→detail is intra-feature navigation, so it is driven by the
|
||||||
* a callback. The detail destination is added here in a later milestone.
|
* [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(
|
fun NavGraphBuilder.charactersGraph(
|
||||||
onCharacterClick: (Int) -> Unit,
|
navController: NavController,
|
||||||
|
onOpenAbout: () -> Unit,
|
||||||
|
onOpenViewsList: () -> Unit,
|
||||||
) {
|
) {
|
||||||
composable<CharacterListRoute> {
|
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() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,19 @@
|
|||||||
<string name="status_alive">Alive</string>
|
<string name="status_alive">Alive</string>
|
||||||
<string name="status_dead">Dead</string>
|
<string name="status_dead">Dead</string>
|
||||||
<string name="status_unknown">Unknown</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>
|
</resources>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation
|
||||||
|
|
||||||
|
sealed interface CharacterDetailAction {
|
||||||
|
data object OnRetry : CharacterDetailAction
|
||||||
|
data object OnBackClick : CharacterDetailAction
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.example.architecture.feature.characters.presentation.di
|
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 com.example.architecture.feature.characters.presentation.CharacterListViewModel
|
||||||
import org.koin.core.module.dsl.viewModelOf
|
import org.koin.core.module.dsl.viewModelOf
|
||||||
import org.koin.dsl.module
|
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 {
|
val charactersPresentationModule = module {
|
||||||
viewModelOf(::CharacterListViewModel)
|
viewModelOf(::CharacterListViewModel)
|
||||||
|
viewModelOf(::CharacterDetailViewModel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = "—"
|
||||||
@@ -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-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
|
||||||
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||||
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
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" }
|
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
|
||||||
|
|
||||||
# --- Coroutines / serialization ---
|
# --- Coroutines / serialization ---
|
||||||
@@ -133,6 +134,7 @@ compose = [
|
|||||||
"androidx-compose-foundation",
|
"androidx-compose-foundation",
|
||||||
"androidx-compose-ui-tooling-preview",
|
"androidx-compose-ui-tooling-preview",
|
||||||
"androidx-compose-material3",
|
"androidx-compose-material3",
|
||||||
|
"androidx-compose-material-icons-core",
|
||||||
]
|
]
|
||||||
koin = ["koin-core", "koin-android"]
|
koin = ["koin-core", "koin-android"]
|
||||||
ktor = [
|
ktor = [
|
||||||
|
|||||||
Reference in New Issue
Block a user