Merge pull request #6 from AdrianKuta/feat/scrub-attribution

REDI-101: Remove AI/tooling attribution from docs & project
This commit is contained in:
2026-06-10 16:59:47 +02:00
committed by GitHub
36 changed files with 97 additions and 132 deletions

2
.gitignore vendored
View File

@@ -16,5 +16,5 @@
# Local config / secrets # Local config / secrets
local.properties local.properties
# IDE (JetBrains / Android Studio) fully ignored to avoid machine-specific churn # IDE (JetBrains / Android Studio) - fully ignored to avoid machine-specific churn
/.idea/ /.idea/

127
README.md
View File

@@ -1,18 +1,17 @@
# Android Architecture Showcase # Android Architecture Showcase
A single, runnable **Android-only (Jetpack Compose)** reference app that demonstrates a complete, A single, runnable **Android-only (Jetpack Compose)** reference app that demonstrates a complete,
idiomatic multi-module architecture each convention shown in its own minimal-but-complete module. idiomatic multi-module architecture - each convention shown in its own minimal-but-complete module.
It is a teaching repo: the goal is not features but *how the pieces fit together*. It is a teaching repo: the goal is not features but *how the pieces fit together*.
Data comes from the no-key [Rick & Morty API](https://rickandmortyapi.com/). The app lists Data comes from the no-key [Rick & Morty API](https://rickandmortyapi.com/). The app lists
characters, opens a detail screen, renders that same list **twice** (Compose and classic Views), has characters, opens a detail screen, renders that same list **twice** (Compose and classic Views), has
a small MVVM *About* screen for contrast, and a dedicated **error-handling demo**. a small MVVM *About* screen for contrast, and a dedicated **error-handling demo**.
> **Status:** built milestone-by-milestone from the > **Status:** built milestone-by-milestone. Foundation, Core Infrastructure, the flagship MVI
> [Linear backlog](https://linear.app/adrian-kuta/project/android-architecture-showcase-b5ecdeddda6c). > feature, Breadth & Contrast, and Quality & Docs are complete; the project assembles green and ships
> Foundation, Core Infrastructure, the flagship MVI feature, Breadth & Contrast, and Quality & Docs > unit + UI tests. The only optional item left is the Room offline-cache stretch (see
> are complete; the project assembles green and ships unit + UI tests. The only optional item left is > [Optional: Room stretch](#optional-room-stretch)).
> the Room offline-cache stretch (see [Optional: Room stretch](#optional-room-stretch)).
--- ---
@@ -29,7 +28,6 @@ a small MVVM *About* screen for contrast, and a dedicated **error-handling demo*
- [Testing](#testing) - [Testing](#testing)
- [Build & run (`android` CLI)](#build--run-android-cli) - [Build & run (`android` CLI)](#build--run-android-cli)
- [Optional: Room stretch](#optional-room-stretch) - [Optional: Room stretch](#optional-room-stretch)
- [Convention skills index](#convention-skills-index)
--- ---
@@ -46,7 +44,7 @@ a small MVVM *About* screen for contrast, and a dedicated **error-handling demo*
| Navigation | type-safe Compose Navigation (`@Serializable` routes) | | Navigation | type-safe Compose Navigation (`@Serializable` routes) |
| Logging | Timber | | Logging | Timber |
| Async | Coroutines + Flow | | Async | Coroutines + Flow |
| Testing | JUnit 5, Turbine, AssertK, `kotlinx-coroutines-test`, Ktor `MockEngine`, Compose UI test | | Testing | JUnit 5, MockK, Turbine, AssertK, `kotlinx-coroutines-test`, Ktor `MockEngine`, Compose UI test |
> **AGP 9 gotcha:** AGP 9.0 has **built-in Kotlin**. Applying `com.android.application`/`library` > **AGP 9 gotcha:** AGP 9.0 has **built-in Kotlin**. Applying `com.android.application`/`library`
> auto-applies the Kotlin Android plugin, so the convention plugins must **not** apply > auto-applies the Kotlin Android plugin, so the convention plugins must **not** apply
@@ -83,14 +81,12 @@ Features never depend on each other; anything shared moves to a `core` module; `
|---|---| |---|---|
| `presentation` | own `domain`, `core:domain`, `core:presentation`, `core:design-system` | | `presentation` | own `domain`, `core:domain`, `core:presentation`, `core:design-system` |
| `data` | own `domain`, `core:domain`, `core:data` | | `data` | own `domain`, `core:domain`, `core:data` |
| `domain` | `core:domain` only never `data` or `presentation` | | `domain` | `core:domain` only - never `data` or `presentation` |
| `:app` | everything | | `:app` | everything |
A key consequence: `:core:presentation`'s `UiText` is **Compose-free**, and the `compose` convention A key consequence: `:core:presentation`'s `UiText` is **Compose-free**, and the `compose` convention
uses `implementation` (not `api`), so the UI-agnostic `:feature:characters:presentation` never gets uses `implementation` (not `api`), so the UI-agnostic `:feature:characters:presentation` never gets
Compose on its classpath which is what lets two different renderers share one ViewModel. Compose on its classpath - which is what lets two different renderers share one ViewModel.
See **android-module-structure**.
--- ---
@@ -102,38 +98,37 @@ One request flows through every layer, each with one job:
Rick & Morty API Rick & Morty API
│ JSON │ JSON
CharacterDto / CharactersResponseDto (:data/dto) serialization shape CharacterDto / CharactersResponseDto (:data/dto) - serialization shape
│ CharacterMapper.toDomain() (:data/mappers) DTO → domain, never the reverse leaks up │ CharacterMapper.toDomain() (:data/mappers) - DTO → domain, never the reverse leaks up
Character / CharactersPage (:domain/model) pure Kotlin domain model Character / CharactersPage (:domain/model) - pure Kotlin domain model
│ CharacterRepository.getCharacters() (:domain contract, :data impl) │ CharacterRepository.getCharacters() (:domain contract, :data impl)
│ GetCharactersPageUseCase(page) (:domain/usecase) domain operation (see note) │ GetCharactersPageUseCase(page) (:domain/usecase) - domain operation (see note)
CharacterListViewModel (:presentation) holds State, processes Action, emits Event CharacterListViewModel (:presentation) - holds State, processes Action, emits Event
│ Character.toCharacterUi() (:presentation/model) domain → UI model (display shaping) │ Character.toCharacterUi() (:presentation/model)- domain → UI model (display shaping)
CharacterUi in CharacterListState (:presentation) immutable UI state CharacterUi in CharacterListState (:presentation) - immutable UI state
CharacterListScreen / CharacterListFragment (:presentation-compose / -views) dumb renderers CharacterListScreen / CharacterListFragment (:presentation-compose / -views) - dumb renderers
``` ```
- **DTOs** (`*Dto`) live in `data`; **domain models** are separate and never become DTOs/entities. - **DTOs** (`*Dto`) live in `data`; **domain models** are separate and never become DTOs/entities.
Mappers are pure extension functions in a `mappers/` package (`toDomain()`). See Mappers are pure extension functions in a `mappers/` package (`toDomain()`).
**android-data-layer**, **android-data-layer-mappers**.
- **UI models** (`*Ui`) live in `presentation` and carry display-ready data (e.g. blank detail fields - **UI models** (`*Ui`) live in `presentation` and carry display-ready data (e.g. blank detail fields
pre-formatted to an em dash). See **android-presentation-mvi**. pre-formatted to an em dash).
### Note when to add a UseCase ### Note - when to add a UseCase
`GetCharactersPageUseCase` is intentionally a **thin pass-through** included to show the convention. The `GetCharactersPageUseCase` is intentionally a **thin pass-through** included to show the convention. The
rule it illustrates: rule it illustrates:
> Add a UseCase when a screen needs **business logic that doesn't belong in the ViewModel** real > Add a UseCase when a screen needs **business logic that doesn't belong in the ViewModel** - real
> rules, or **composition of several repositories/sources** into one operation. When the ViewModel > rules, or **composition of several repositories/sources** into one operation. When the ViewModel
> would merely forward a single repository call, injecting the repository directly is fine. > would merely forward a single repository call, injecting the repository directly is fine.
Here the list VM uses the UseCase; the detail VM calls `CharacterRepository` directly both are Here the list VM uses the UseCase; the detail VM calls `CharacterRepository` directly - both are
correct, and the contrast is the point. See **android-module-structure**, **android-di-koin**. correct, and the contrast is the point.
--- ---
@@ -145,41 +140,39 @@ Both patterns live side by side so the trade-off is concrete.
|---|---|---| |---|---|---|
| State | one immutable `State` data class | one immutable `State` data class | | 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` | | 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` | | Side effects | one-time `Event`s via a `Channel` (nav, snackbar) | none - the screen calls a method / uses `LocalUriHandler` |
| Best when | state is complex and interacting; effects matter | the screen is small and mostly static | | Best when | state is complex and interacting; effects matter | the screen is small and mostly static |
The flagship list is **MVI** because its state is genuinely complex pagination, loading vs. The flagship list is **MVI** because its state is genuinely complex - pagination, loading vs.
next-page loading, error surfacing, `SavedStateHandle` restore after process death and it emits next-page loading, error surfacing, `SavedStateHandle` restore after process death - and it emits
navigation/snackbar effects. *About* is deliberately **MVVM**: a `StateFlow` plus a couple of public navigation/snackbar effects. *About* 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 methods, with **no `Action` and no `Event` types at all**, because that ceremony would be pure
overhead for static content. overhead for static content.
### Note Events vs State ### Note - Events vs State
State is what the screen **is** (re-rendered on every change, survives recomposition/rotation). State is what the screen **is** (re-rendered on every change, survives recomposition/rotation).
Events are things that happen **once** navigate, show a snackbar. Modeling a one-time effect as Events are things that happen **once** - navigate, show a snackbar. Modeling a one-time effect as
state causes it to re-fire on rotation; modeling durable data as an event drops it. MVI keeps them state causes it to re-fire on rotation; modeling durable data as an event drops it. MVI keeps them
separate (`StateFlow` vs `Channel`); the Compose side consumes events with `ObserveAsEvents`, the separate (`StateFlow` vs `Channel`); the Compose side consumes events with `ObserveAsEvents`, the
Views side with `repeatOnLifecycle`. Views side with `repeatOnLifecycle`.
### Note when MVVM is acceptable ### Note - when MVVM is acceptable
Reach for MVI when state is complex **and** side effects matter. Reach for plain MVVM when the screen Reach for MVI when state is complex **and** side effects matter. Reach for plain MVVM when the screen
is small, mostly static, and has no real side effects the *About* screen is the canonical case. is small, mostly static, and has no real side effects - the *About* screen is the canonical case.
See **android-presentation-mvi**, **android-compose-ui**.
--- ---
## One ViewModel, two renderers (Compose vs Views) ## One ViewModel, two renderers (Compose vs Views)
`:feature:characters:presentation` is **UI-toolkit-agnostic** no Compose *and* no Views dependency. `:feature:characters:presentation` is **UI-toolkit-agnostic** - no Compose *and* no Views dependency.
State stays Compose-stable via `kotlinx-collections-immutable` (`ImmutableList`) rather than the State stays Compose-stable via `kotlinx-collections-immutable` (`ImmutableList`) rather than the
`@Stable` annotation (which would pull in compose-runtime). The exact same `CharacterListViewModel` `@Stable` annotation (which would pull in compose-runtime). The exact same `CharacterListViewModel`
(State/Action/Event/UI-model) is rendered twice: (State/Action/Event/UI-model) is rendered twice:
- `:feature:characters:presentation-compose` Jetpack Compose (`LazyColumn`). - `:feature:characters:presentation-compose` - Jetpack Compose (`LazyColumn`).
- `:feature:characters:presentation-views` `Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`, - `:feature:characters:presentation-views` - `Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`,
resolving the **same** Koin `CharacterListViewModel` via `by viewModel()`. resolving the **same** Koin `CharacterListViewModel` via `by viewModel()`.
`:app` hosts the Views renderer inside the Compose `NavHost` via `AndroidFragment` (Compose↔View `:app` hosts the Views renderer inside the Compose `NavHost` via `AndroidFragment` (Compose↔View
@@ -191,8 +184,6 @@ from navigation.
> the classic Views (e.g. `MaterialToolbar`, `?attr/colorOnSurfaceVariant`) require. A plain > the classic Views (e.g. `MaterialToolbar`, `?attr/colorOnSurfaceVariant`) require. A plain
> `ComponentActivity` or a non-Material theme breaks the Fragment renderer. > `ComponentActivity` or a non-Material theme breaks the Fragment renderer.
See **android-compose-ui**, **android-module-structure**.
--- ---
## Errors: `Result`, `DataError`, `UiText` ## Errors: `Result`, `DataError`, `UiText`
@@ -205,7 +196,7 @@ sealed interface DataError : Error { enum Network { NO_INTERNET, NOT_FOUND, SERV
``` ```
- The **data layer** catches transport/parse exceptions at the boundary (`safeCall` in `:core:data`) - The **data layer** catches transport/parse exceptions at the boundary (`safeCall` in `:core:data`)
and converts them to `Result.Error(DataError.Network.*)` HTTP status → typed error, and a and converts them to `Result.Error(DataError.Network.*)` - HTTP status → typed error, and a
malformed body → `SERIALIZATION` (the cause chain is unwrapped because Ktor wraps the kotlinx malformed body → `SERIALIZATION` (the cause chain is unwrapped because Ktor wraps the kotlinx
`SerializationException`). Upper layers never see raw exceptions. `SerializationException`). Upper layers never see raw exceptions.
- The **presentation layer** maps a `DataError` to user-facing **`UiText`** via `DataError.toUiText()` - The **presentation layer** maps a `DataError` to user-facing **`UiText`** via `DataError.toUiText()`
@@ -230,8 +221,6 @@ Three distinct cases (`NO_INTERNET`, `NOT_FOUND`, `SERVER_ERROR`) each render th
**Retry** re-issues the last request as an Action; a successful load **clears** the error. The same **Retry** re-issues the last request as an Action; a successful load **clears** the error. The same
`ErrorState` + retry Action is what the real list and detail screens use. `ErrorState` + retry Action is what the real list and detail screens use.
See **android-error-handling**, **android-presentation-mvi**.
--- ---
## Navigation ## Navigation
@@ -248,21 +237,19 @@ in `:app`.
- **Intra-feature** navigation (list → detail, list → error demo) is driven by the `NavController` - **Intra-feature** navigation (list → detail, list → error demo) is driven by the `NavController`
passed into `charactersGraph(navController, …)`. passed into `charactersGraph(navController, …)`.
- **Cross-feature / cross-toolkit** destinations (About, the Views renderer) are exposed as **lambda - **Cross-feature / cross-toolkit** destinations (About, the Views renderer) are exposed as **lambda
callbacks** supplied by `:app` a feature never imports another feature's route. callbacks** supplied by `:app` - a feature never imports another feature's route.
- **Nav args without a nav dependency:** type-safe nav serializes `CharacterDetailRoute.characterId` - **Nav args without a nav dependency:** type-safe nav serializes `CharacterDetailRoute.characterId`
into the destination's arguments, which Navigation copies into the ViewModel's `SavedStateHandle`. into the destination's arguments, which Navigation copies into the ViewModel's `SavedStateHandle`.
`CharacterDetailViewModel` reads `savedStateHandle.get<Int>("characterId")` by field name so the `CharacterDetailViewModel` reads `savedStateHandle.get<Int>("characterId")` by field name - so the
UI-agnostic `presentation` module needs **no** navigation dependency. The same `SavedStateHandle` UI-agnostic `presentation` module needs **no** navigation dependency. The same `SavedStateHandle`
also persists the list's loaded page across process death. also persists the list's loaded page across process death.
See **android-navigation**.
--- ---
## Dependency injection (Koin) ## Dependency injection (Koin)
One Koin module per feature layer (only if it has something to provide), all assembled in One Koin module per feature layer (only if it has something to provide), all assembled in
`ArchitectureApp` never inside feature modules. Prefer the **constructor DSL**: `ArchitectureApp` - never inside feature modules. Prefer the **constructor DSL**:
```kotlin ```kotlin
// :feature:characters:data // :feature:characters:data
@@ -281,30 +268,28 @@ val charactersPresentationModule = module {
``` ```
The lambda form (`single { … }`) appears only where a constructor reference can't express the binding The lambda form (`single { … }`) appears only where a constructor reference can't express the binding
e.g. `single { HttpClientFactory.create(OkHttp.create()) }` in `coreDataModule` (a factory call, - e.g. `single { HttpClientFactory.create(OkHttp.create()) }` in `coreDataModule` (a factory call,
not a constructor). Compose roots inject with `koinViewModel()`; the Fragment uses `by viewModel()` not a constructor). Compose roots inject with `koinViewModel()`; the Fragment uses `by viewModel()` -
both resolve the **same** `CharacterListViewModel` class and supply its `SavedStateHandle`. both resolve the **same** `CharacterListViewModel` class and supply its `SavedStateHandle`.
See **android-di-koin**, **koin-constructor-dsl**.
--- ---
## Testing ## Testing
Tests prove the architecture, not just the code. Stack: **JUnit 5**, **Turbine** (Flow), **AssertK**, Tests prove the architecture, not just the code. Stack: **JUnit 5**, **MockK**, **Turbine** (Flow),
`kotlinx-coroutines-test`, Ktor **`MockEngine`**, and Compose UI test. **AssertK**, `kotlinx-coroutines-test`, Ktor **`MockEngine`**, and Compose UI test.
| What | Where | Kind | | What | Where | Kind |
|---|---|---| |---|---|---|
| `GetCharactersPageUseCase` | `:feature:characters:domain` `src/test` | pure JVM, JUnit 5 | | `GetCharactersPageUseCase` | `:feature:characters:domain` `src/test` | pure JVM, JUnit 5 |
| `CharacterListViewModel`, `CharacterDetailViewModel` | `:feature:characters:presentation` `src/test` | JVM unit, fakes + Turbine + `SavedStateHandle` | | `CharacterListViewModel`, `CharacterDetailViewModel` | `:feature:characters:presentation` `src/test` | JVM unit, MockK + Turbine + `SavedStateHandle` |
| `NetworkCharacterRepository` | `:feature:characters:data` `src/test` | JVM unit, Ktor `MockEngine` | | `NetworkCharacterRepository` | `:feature:characters:data` `src/test` | JVM unit, Ktor `MockEngine` |
| `CharacterListScreen` (robot) | `:feature:characters:presentation-compose` `src/androidTest` | instrumented Compose UI | | `CharacterListScreen` (robot) | `:feature:characters:presentation-compose` `src/androidTest` | instrumented Compose UI |
Conventions demonstrated: Conventions demonstrated:
- **Fakes, not mocks.** `FakeCharacterRepository` is a real in-memory implementation with a - **MockK for collaborators.** The ViewModel/UseCase tests stub the `CharacterRepository` interface
`failWith` toggle and call counts — tests assert against working behaviour, not recorded calls. with MockK - `coEvery` scripts the suspend calls, `coVerify` asserts the paging/retry interactions.
- **VM tested through its public MVI surface** (State/Action/Event) with a directly-constructed - **VM tested through its public MVI surface** (State/Action/Event) with a directly-constructed
`SavedStateHandle`, so the same tests hold for either renderer. Coverage includes happy path, `SavedStateHandle`, so the same tests hold for either renderer. Coverage includes happy path,
error → `UiText` + snackbar `Event`, pagination end-reached, **process-death restore**, and the error → `UiText` + snackbar `Event`, pagination end-reached, **process-death restore**, and the
@@ -318,7 +303,7 @@ Conventions demonstrated:
> **JUnit 5 on AGP 9:** the `de.mannodermaus.android-junit5` Gradle plugin targets AGP 8.x, so this > **JUnit 5 on AGP 9:** the `de.mannodermaus.android-junit5` Gradle plugin targets AGP 8.x, so this
> repo doesn't use it. `AndroidUnitTest` extends Gradle's `Test`, so the `architecture.android.unit.test` > repo doesn't use it. `AndroidUnitTest` extends Gradle's `Test`, so the `architecture.android.unit.test`
> convention plugin just calls `useJUnitPlatform()` and adds the `unit-test` bundle including the > convention plugin just calls `useJUnitPlatform()` and adds the `unit-test` bundle - including the
> `junit-platform-launcher`, which Gradle 9 no longer bundles. > `junit-platform-launcher`, which Gradle 9 no longer bundles.
> **Espresso + API 34+:** Compose's test rule drives Espresso's `onIdle`, and transitive Espresso > **Espresso + API 34+:** Compose's test rule drives Espresso's `onIdle`, and transitive Espresso
@@ -330,8 +315,6 @@ runs on a device/emulator via `./gradlew :feature:characters:presentation-compos
(CI compiles it via `assembleDebugAndroidTest`). An Espresso test for the Fragment renderer is (CI compiles it via `assembleDebugAndroidTest`). An Espresso test for the Fragment renderer is
possible but intentionally omitted (the VM logic is already covered by the shared unit tests). possible but intentionally omitted (the VM logic is already covered by the shared unit tests).
See **android-testing**.
--- ---
## Build & run (`android` CLI) ## Build & run (`android` CLI)
@@ -362,27 +345,9 @@ Requires JDK 17+ (the Gradle build pins a Java 17 toolchain) and the Android SDK
## Optional: Room stretch ## Optional: Room stretch
Out of core scope and **not implemented** (tracked as the optional REDI-99). It would add a `room` Out of core scope and **not implemented** (an optional stretch). It would add a `room`
convention plugin and a `:core:database` (or feature Room set) with `CharacterEntity` + DAO + convention plugin and a `:core:database` (or feature Room set) with `CharacterEntity` + DAO +
`@Database` (prefer `autoMigrations`), then convert the repository to **offline-first** `@Database` (prefer `autoMigrations`), then convert the repository to **offline-first**
(`OfflineFirstCharacterRepository`: network → persist → expose a DB `Flow`; the ViewModel observes the (`OfflineFirstCharacterRepository`: network → persist → expose a DB `Flow`; the ViewModel observes the
DB, never the network response). The current `CharacterRepository` returning the `DataError` DB, never the network response). The current `CharacterRepository` returning the `DataError`
supertype already anticipates a multi-source implementation. See **android-data-layer**. supertype already anticipates a multi-source implementation.
---
## Convention skills index
This repo is a narrative index of these conventions:
| Skill | Where it shows up |
|---|---|
| android-module-structure | module graph, dependency rules, convention plugins |
| android-presentation-mvi | characters list/detail/error-demo (State/Action/Event/VM) |
| android-compose-ui | Compose renderers, design-system, previews, stability |
| android-navigation | type-safe routes, per-feature graphs, callback decoupling |
| android-di-koin / koin-constructor-dsl | feature Koin modules, `*Of` constructor DSL |
| android-data-layer / android-data-layer-mappers | data sources, repository, DTOs, mappers |
| android-error-handling | `Result`/`DataError`/`UiText`, `safeCall`, the error demo |
| android-testing | unit tests, fakes, `MockEngine`, the robot UI test |
| android-cli | build/run/emulator steps above |

View File

@@ -35,9 +35,9 @@ dependencies {
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
// Compose↔View interop: hosts a Fragment inside the Compose NavHost. // Compose↔View interop: hosts a Fragment inside the Compose NavHost.
implementation(libs.androidx.fragment.compose) 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.
implementation(libs.timber) implementation(libs.timber)
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View File

@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
/** /**
* Route for the characters list rendered with the classic **Views** toolkit. It lives in `:app` * 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 * because `:app` owns Compose↔View interop - the `:feature:characters:presentation-views` module
* stays navigation-agnostic (it knows nothing about Compose Navigation or this route). * stays navigation-agnostic (it knows nothing about Compose Navigation or this route).
*/ */
@Serializable @Serializable

View File

@@ -11,7 +11,7 @@ import org.gradle.kotlin.dsl.withType
* shared `unit-test` toolset (JUnit Jupiter, kotlinx-coroutines-test, Turbine, AssertK). * shared `unit-test` toolset (JUnit Jupiter, kotlinx-coroutines-test, Turbine, AssertK).
* *
* Deliberately does NOT use the `de.mannodermaus.android-junit5` Gradle plugin: its 1.11.x line * Deliberately does NOT use the `de.mannodermaus.android-junit5` Gradle plugin: its 1.11.x line
* targets AGP 8.x and we build on AGP 9.0. It isn't needed for *local* unit tests anyway * targets AGP 8.x and we build on AGP 9.0. It isn't needed for *local* unit tests anyway -
* `com.android.build.gradle.tasks.factory.AndroidUnitTest` extends Gradle's [Test] task, so calling * `com.android.build.gradle.tasks.factory.AndroidUnitTest` extends Gradle's [Test] task, so calling
* `useJUnitPlatform()` on it is enough (this mirrors `DomainModuleConventionPlugin`, which does the * `useJUnitPlatform()` on it is enough (this mirrors `DomainModuleConventionPlugin`, which does the
* same for pure-JVM modules). * same for pure-JVM modules).

View File

@@ -27,7 +27,7 @@ class ComposeConventionPlugin : Plugin<Project> {
dependencies { dependencies {
// `implementation` (not api): every Compose consumer applies this convention itself, so // `implementation` (not api): every Compose consumer applies this convention itself, so
// Compose must NOT leak transitively that keeps the UI-agnostic presentation module // Compose must NOT leak transitively - that keeps the UI-agnostic presentation module
// (which depends on core:presentation) free of Compose. // (which depends on core:presentation) free of Compose.
val bom = platform(libs.findLibrary("androidx-compose-bom").get()) val bom = platform(libs.findLibrary("androidx-compose-bom").get())
add("implementation", bom) add("implementation", bom)

View File

@@ -6,7 +6,7 @@ import io.ktor.client.engine.okhttp.OkHttp
import org.koin.dsl.module import org.koin.dsl.module
/** /**
* Core data DI: the single shared [HttpClient]. This is the one sanctioned lambda-DSL binding * Core data DI: the single shared [HttpClient]. This is the one sanctioned lambda-DSL binding -
* HttpClient is assembled by a factory plus the OkHttp engine (not a plain constructor), so the * HttpClient is assembled by a factory plus the OkHttp engine (not a plain constructor), so the
* constructor DSL (`singleOf`) cannot express it. Feature data modules append their own bindings. * constructor DSL (`singleOf`) cannot express it. Feature data modules append their own bindings.
*/ */

View File

@@ -98,7 +98,7 @@ internal fun logNetworkError(throwable: Throwable, message: String) {
Timber.tag("HttpClient").e(throwable, message) Timber.tag("HttpClient").e(throwable, message)
} }
/** Maps HTTP status codes to typed [DataError.Network] (extends the skill table with 400/403/404). */ /** Maps HTTP status codes to typed [DataError.Network] (covering 400/403/404 as well). */
suspend inline fun <reified T> responseToResult( suspend inline fun <reified T> responseToResult(
response: HttpResponse, response: HttpResponse,
): Result<T, DataError.Network> { ): Result<T, DataError.Network> {

View File

@@ -4,7 +4,7 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
// Brand palette seeded from the Android green used by the project. // Brand palette - seeded from the Android green used by the project.
private val Green10 = Color(0xFF00210B) private val Green10 = Color(0xFF00210B)
private val Green20 = Color(0xFF003918) private val Green20 = Color(0xFF003918)
private val Green40 = Color(0xFF1E6C36) private val Green40 = Color(0xFF1E6C36)

View File

@@ -13,7 +13,7 @@ sealed interface Result<out D, out E : Error> {
) : Result<Nothing, E> ) : Result<Nothing, E>
} }
/** A [Result] that carries no success payload for operations that either succeed or fail. */ /** A [Result] that carries no success payload - for operations that either succeed or fail. */
typealias EmptyResult<E> = Result<Unit, E> typealias EmptyResult<E> = Result<Unit, E>
inline fun <T, E : Error, R> Result<T, E>.map(map: (T) -> R): Result<R, E> { inline fun <T, E : Error, R> Result<T, E>.map(map: (T) -> R): Result<R, E> {

View File

@@ -9,7 +9,7 @@ import kotlinx.serialization.Serializable
data object AboutRoute data object AboutRoute
/** /**
* The About feature nav graph. It only needs a "go back" callback `:app` wires it to the shared * 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. * NavController, keeping this feature decoupled from how it is reached.
*/ */
fun NavGraphBuilder.aboutGraph( fun NavGraphBuilder.aboutGraph(

View File

@@ -30,7 +30,7 @@ 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 * 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 * [AboutState] and passes the ViewModel's **method reference** straight through - there is no
* `onAction` funnel and no event observation, because this screen has neither. * `onAction` funnel and no event observation, because this screen has neither.
*/ */
@Composable @Composable
@@ -88,7 +88,7 @@ fun AboutScreen(
Text(text = "$highlight", style = MaterialTheme.typography.bodyMedium) Text(text = "$highlight", style = MaterialTheme.typography.bodyMedium)
} }
// The expandable card is driven entirely by the VM's plain method the MVVM contrast. // The expandable card is driven entirely by the VM's plain method - the MVVM contrast.
AppCard( AppCard(
onClick = onToggleMvvmNote, onClick = onToggleMvvmNote,
header = { header = {

View File

@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
/** /**
* **MVVM the deliberate contrast to the app's MVI screens.** * **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 * 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: * invokes the ViewModel's **plain public methods** directly. That is the whole point of this screen:
@@ -37,7 +37,7 @@ class AboutViewModel : ViewModel() {
), ),
mvvmNote = "MVI funnels every user intent through a single onAction(Action) entry point " + 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 " + "and emits one-time effects (navigation, snackbars) through an Event channel. That " +
"structure pays off when state is complex and interacting like the paginated, " + "structure pays off when state is complex and interacting - like the paginated, " +
"process-death-restorable characters list. This screen is intentionally MVVM instead: " + "process-death-restorable characters list. This screen is intentionally MVVM instead: " +
"the ViewModel exposes a StateFlow plus plain public methods (onToggleMvvmNote), with " + "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 " + "no Action or Event types at all. Rule of thumb: reach for MVI when state is complex " +
@@ -56,7 +56,7 @@ class AboutViewModel : ViewModel() {
) )
val state: StateFlow<AboutState> = _state.asStateFlow() val state: StateFlow<AboutState> = _state.asStateFlow()
/** MVVM: a plain public method mutates state directly no Action object, no reducer funnel. */ /** MVVM: a plain public method mutates state directly - no Action object, no reducer funnel. */
fun onToggleMvvmNote() { fun onToggleMvvmNote() {
_state.update { it.copy(showMvvmNote = !it.showMvvmNote) } _state.update { it.copy(showMvvmNote = !it.showMvvmNote) }
} }

View File

@@ -8,7 +8,7 @@ import com.example.architecture.feature.characters.data.dto.CharactersResponseDt
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
/** /**
* Remote data source for characters. Returns raw DTOs (no mapping here the repository maps via * Remote data source for characters. Returns raw DTOs (no mapping here - the repository maps via
* CharacterMapper). Errors already surface as [DataError.Network] from the typed `get` helper. * CharacterMapper). Errors already surface as [DataError.Network] from the typed `get` helper.
*/ */
internal class KtorCharacterDataSource( internal class KtorCharacterDataSource(

View File

@@ -22,7 +22,7 @@ import org.junit.jupiter.api.Test
/** /**
* Data-layer test for [NetworkCharacterRepository]. A Ktor [MockEngine] is swapped into the real * Data-layer test for [NetworkCharacterRepository]. A Ktor [MockEngine] is swapped into the real
* [HttpClientFactory] (`create(engine)` takes the engine precisely so tests can do this) so the * [HttpClientFactory] (`create(engine)` takes the engine precisely so tests can do this) - so the
* full path under test is genuine: Ktor request → status/JSON handling in `safeCall` → DTO mapping → * full path under test is genuine: Ktor request → status/JSON handling in `safeCall` → DTO mapping →
* domain model. Covers success mapping, a 404 and a 5xx mapped to typed [DataError.Network], and a * domain model. Covers success mapping, a 404 and a 5xx mapped to typed [DataError.Network], and a
* malformed-body → SERIALIZATION case. * malformed-body → SERIALIZATION case.

View File

@@ -9,14 +9,14 @@ import com.example.architecture.feature.characters.domain.model.CharactersPage
* Loads one page of characters. * Loads one page of characters.
* *
* **When to add a UseCase (convention note):** introduce a UseCase when a screen needs business * **When to add a UseCase (convention note):** introduce a UseCase when a screen needs business
* logic that does NOT belong in the ViewModel non-trivial rules, or *composition* of several * logic that does NOT belong in the ViewModel - non-trivial rules, or *composition* of several
* repositories/sources into one domain operation. When the ViewModel would merely forward a single * repositories/sources into one domain operation. When the ViewModel would merely forward a single
* repository call, skipping the UseCase and injecting the repository directly is perfectly fine. * repository call, skipping the UseCase and injecting the repository directly is perfectly fine.
* *
* This particular UseCase is a **thin pass-through, included for illustration**: it adds no logic * This particular UseCase is a **thin pass-through, included for illustration**: it adds no logic
* beyond delegating to [CharacterRepository]. It earns its place only as a showcase of the * beyond delegating to [CharacterRepository]. It earns its place only as a showcase of the
* convention (domain-owned, `operator fun invoke`, constructor-injected). In a real app you would * convention (domain-owned, `operator fun invoke`, constructor-injected). In a real app you would
* grow it the moment list loading gained real behaviour (filtering, merging a local cache, …) or * grow it the moment list loading gained real behaviour (filtering, merging a local cache, …) - or
* delete it and let the ViewModel call the repository. * delete it and let the ViewModel call the repository.
*/ */
class GetCharactersPageUseCase( class GetCharactersPageUseCase(

View File

@@ -17,7 +17,7 @@ import org.junit.jupiter.api.Test
/** /**
* Tests for the (thin pass-through) [GetCharactersPageUseCase]: it must forward the requested page to * Tests for the (thin pass-through) [GetCharactersPageUseCase]: it must forward the requested page to
* the repository and return its result verbatim success and error alike. Pure JVM test on the * the repository and return its result verbatim - success and error alike. Pure JVM test on the
* JUnit 5 platform (see DomainModuleConventionPlugin); the [CharacterRepository] collaborator is a * JUnit 5 platform (see DomainModuleConventionPlugin); the [CharacterRepository] collaborator is a
* MockK mock, stubbed with `coEvery` and verified with `coVerify`. * MockK mock, stubbed with `coEvery` and verified with `coVerify`.
*/ */

View File

@@ -13,8 +13,8 @@ import org.junit.Assert.assertTrue
/** /**
* Robot for [CharacterListScreen] UI tests. Each method returns `this` so calls read as a fluent * Robot for [CharacterListScreen] UI tests. Each method returns `this` so calls read as a fluent
* scenario (`robot.setContent(state).assertCharacterShown(...).clickCharacter(...)`). The robot owns * scenario (`robot.setContent(state).assertCharacterShown(...).clickCharacter(...)`). The robot owns
* the interaction vocabulary; the test owns the assertions' intent keeping tests readable and * the interaction vocabulary; the test owns the assertions' intent - keeping tests readable and
* resilient to UI structure changes. See android-testing. * resilient to UI structure changes.
*/ */
class CharacterListRobot( class CharacterListRobot(
private val composeRule: ComposeContentTestRule, private val composeRule: ComposeContentTestRule,

View File

@@ -68,7 +68,7 @@ fun CharacterDetailRoot(
CharacterDetailScreen(state = state, onAction = viewModel::onAction) CharacterDetailScreen(state = state, onAction = viewModel::onAction)
} }
/** Pure, stateless screen previewable without a ViewModel. */ /** Pure, stateless screen - previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CharacterDetailScreen( fun CharacterDetailScreen(

View File

@@ -101,7 +101,7 @@ fun CharacterListRoot(
) )
} }
/** Pure, stateless screen previewable without a ViewModel. */ /** Pure, stateless screen - previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CharacterListScreen( fun CharacterListScreen(

View File

@@ -9,7 +9,7 @@ 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. */ /** Type-safe route for the character detail screen - carries only the typed id, never an object. */
@Serializable @Serializable
data class CharacterDetailRoute(val characterId: Int) data class CharacterDetailRoute(val characterId: Int)
@@ -39,7 +39,7 @@ fun NavGraphBuilder.charactersGraph(
} }
composable<CharacterDetailRoute> { composable<CharacterDetailRoute> {
// The typed CharacterDetailRoute serializes `characterId` into the destination's arguments, // The typed CharacterDetailRoute serializes `characterId` into the destination's arguments,
// which Navigation copies into the ViewModel's SavedStateHandle that is where // which Navigation copies into the ViewModel's SavedStateHandle - that is where
// CharacterDetailViewModel reads it (keeping that module free of any navigation dependency). // CharacterDetailViewModel reads it (keeping that module free of any navigation dependency).
CharacterDetailRoot(onNavigateBack = { navController.popBackStack() }) CharacterDetailRoot(onNavigateBack = { navController.popBackStack() })
} }

View File

@@ -57,7 +57,7 @@ fun ErrorDemoRoot(
ErrorDemoScreen(state = state, onAction = viewModel::onAction) ErrorDemoScreen(state = state, onAction = viewModel::onAction)
} }
/** Pure, stateless screen previewable without a ViewModel. */ /** Pure, stateless screen - previewable without a ViewModel. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ErrorDemoScreen( fun ErrorDemoScreen(

View File

@@ -23,7 +23,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
/** /**
* Classic Views renderer for the characters list. It drives the **same** [CharacterListViewModel] as * 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 * 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`). * UI-agnostic. Koin's `by viewModel()` supplies the VM (and its `SavedStateHandle`).
* *
* `:app` (the interop owner) wires [onCharacterClick] / [onNavigateBack]; the Fragment never touches * `:app` (the interop owner) wires [onCharacterClick] / [onNavigateBack]; the Fragment never touches

View File

@@ -6,7 +6,7 @@ import com.example.architecture.feature.characters.domain.model.CharacterStatus
/** /**
* Views-renderer presentation helpers for [CharacterStatus]. These intentionally mirror the Compose * 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 * 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. * owns its own resources, so the small label duplication across modules is expected.
*/ */
@StringRes @StringRes

View File

@@ -18,7 +18,7 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.savedstate) implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
// Stable collection for state makes the list Compose-stable WITHOUT a Compose dependency, // Stable collection for state - makes the list Compose-stable WITHOUT a Compose dependency,
// so this module stays UI-agnostic (no @Stable annotation, which would require compose-runtime). // so this module stays UI-agnostic (no @Stable annotation, which would require compose-runtime).
// `api` because CharacterListState.characters exposes ImmutableList in the public state API. // `api` because CharacterListState.characters exposes ImmutableList in the public state API.
api(libs.kotlinx.collections.immutable) api(libs.kotlinx.collections.immutable)

View File

@@ -19,7 +19,7 @@ import kotlinx.coroutines.launch
* UI-agnostic MVI ViewModel for the character detail screen. * UI-agnostic MVI ViewModel for the character detail screen.
* *
* Type-safe navigation writes the route's typed `characterId` into [SavedStateHandle] under its * Type-safe navigation writes the route's typed `characterId` into [SavedStateHandle] under its
* field name. Reading that raw key instead of `savedStateHandle.toRoute<CharacterDetailRoute>()` * 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 * 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()`. * lives in the renderer). The renderer is what reads the route via `toRoute()`.
*/ */

View File

@@ -8,7 +8,7 @@ import kotlinx.collections.immutable.persistentListOf
/** /**
* The single source of UI state for the characters list. Deliberately Compose-free: instead of the * The single source of UI state for the characters list. Deliberately Compose-free: instead of the
* `@Stable` annotation (which lives in compose-runtime), the list is an [ImmutableList], which * `@Stable` annotation (which lives in compose-runtime), the list is an [ImmutableList], which
* Compose already treats as stable so this module needs no Compose dependency. Navigation and * Compose already treats as stable - so this module needs no Compose dependency. Navigation and
* snackbars are one-time Events, never state. * snackbars are one-time Events, never state.
*/ */
data class CharacterListState( data class CharacterListState(

View File

@@ -77,7 +77,7 @@ class CharacterListViewModel(
page++ page++
} }
// Always surface a failure even a partial one where earlier pages loaded. // Always surface a failure - even a partial one where earlier pages loaded.
if (error != null) { if (error != null) {
_events.send(CharacterListEvent.ShowSnackbar(error)) _events.send(CharacterListEvent.ShowSnackbar(error))
} }
@@ -119,7 +119,7 @@ class CharacterListViewModel(
private fun loadPage(page: Int) { private fun loadPage(page: Int) {
// Flip the loading flag SYNCHRONOUSLY (before launching) so a rapid second OnLoadNextPage is // Flip the loading flag SYNCHRONOUSLY (before launching) so a rapid second OnLoadNextPage is
// guarded out before its coroutine starts otherwise the same page loads twice and items // guarded out before its coroutine starts - otherwise the same page loads twice and items
// get appended twice. // get appended twice.
_state.update { it.copy(isLoadingNextPage = true, error = null) } _state.update { it.copy(isLoadingNextPage = true, error = null) }
viewModelScope.launch { viewModelScope.launch {

View File

@@ -4,7 +4,7 @@ sealed interface ErrorDemoAction {
/** Force a load that fails with the given [ErrorScenario]. */ /** Force a load that fails with the given [ErrorScenario]. */
data class OnForceError(val scenario: ErrorScenario) : ErrorDemoAction data class OnForceError(val scenario: ErrorScenario) : ErrorDemoAction
/** Force a load that succeeds clears any current error. */ /** Force a load that succeeds - clears any current error. */
data object OnLoadSuccess : ErrorDemoAction data object OnLoadSuccess : ErrorDemoAction
/** Re-issue the most recent load (the design-system retry button). */ /** Re-issue the most recent load (the design-system retry button). */

View File

@@ -4,8 +4,8 @@ import com.example.architecture.core.presentation.UiText
/** /**
* State for the error-handling demo. All fields are primitive/stable, so no `@Stable` is needed. * State for the error-handling demo. All fields are primitive/stable, so no `@Stable` is needed.
* [error] is the *mapped* [UiText] produced by `DataError.toUiText()` exactly what the real * [error] is the *mapped* [UiText] produced by `DataError.toUiText()` - exactly what the real
* screens hold so the renderer resolves and shows it the same way. * screens hold - so the renderer resolves and shows it the same way.
*/ */
data class ErrorDemoState( data class ErrorDemoState(
val isLoading: Boolean = false, val isLoading: Boolean = false,

View File

@@ -16,7 +16,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* UI-agnostic MVI ViewModel for the **error-handling demo** a runnable walk-through of the whole * UI-agnostic MVI ViewModel for the **error-handling demo** - a runnable walk-through of the whole
* error pipeline. A "force error" affordance produces a real [DataError.Network], which is routed * error pipeline. A "force error" affordance produces a real [DataError.Network], which is routed
* through the *same* steps a genuine network call uses: * through the *same* steps a genuine network call uses:
* *
@@ -24,10 +24,10 @@ import kotlinx.coroutines.launch
* Result<…, DataError.Network> → onSuccess / onFailure → DataError.toUiText() → ErrorState * Result<…, DataError.Network> → onSuccess / onFailure → DataError.toUiText() → ErrorState
* ``` * ```
* *
* The outcome is *simulated* (no real request) only so every case including NO_INTERNET, which you * The outcome is *simulated* (no real request) only so every case - including NO_INTERNET, which you
* can't reliably trigger on demand is reachable deterministically. [OnRetry] re-issues the last * can't reliably trigger on demand - is reachable deterministically. [OnRetry] re-issues the last
* attempt (proving retry is an Action); [OnLoadSuccess] clears the error (proving it clears on * attempt (proving retry is an Action); [OnLoadSuccess] clears the error (proving it clears on
* success). See android-error-handling. * success).
*/ */
class ErrorDemoViewModel : ViewModel() { class ErrorDemoViewModel : ViewModel() {

View File

@@ -10,8 +10,8 @@ import org.koin.dsl.module
/** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModels it provides. */ /** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModels it provides. */
val charactersPresentationModule = module { val charactersPresentationModule = module {
// Stateless domain UseCase `factoryOf` (a fresh, cheap instance per resolution). Koin supplies // Stateless domain UseCase - `factoryOf` (a fresh, cheap instance per resolution). Koin supplies
// its CharacterRepository from charactersDataModule. See koin-constructor-dsl. // its CharacterRepository from charactersDataModule.
factoryOf(::GetCharactersPageUseCase) factoryOf(::GetCharactersPageUseCase)
viewModelOf(::CharacterListViewModel) viewModelOf(::CharacterListViewModel)
viewModelOf(::CharacterDetailViewModel) viewModelOf(::CharacterDetailViewModel)

View File

@@ -3,7 +3,7 @@ package com.example.architecture.feature.characters.presentation.model
import com.example.architecture.feature.characters.domain.model.Character import com.example.architecture.feature.characters.domain.model.Character
import com.example.architecture.feature.characters.domain.model.CharacterStatus import com.example.architecture.feature.characters.domain.model.CharacterStatus
/** Presentation model for a character list item decouples the UI from the domain [Character]. */ /** Presentation model for a character list item - decouples the UI from the domain [Character]. */
data class CharacterUi( data class CharacterUi(
val id: Int, val id: Int,
val name: String, val name: String,

View File

@@ -27,7 +27,7 @@ import org.junit.jupiter.api.assertThrows
/** /**
* Unit tests for [CharacterDetailViewModel]. The character id arrives via [SavedStateHandle] (written * Unit tests for [CharacterDetailViewModel]. The character id arrives via [SavedStateHandle] (written
* by type-safe navigation), which is constructed directly here proving the VM needs no navigation * by type-safe navigation), which is constructed directly here - proving the VM needs no navigation
* dependency. The [CharacterRepository] collaborator is a *relaxed* MockK mock, so the "missing id" * dependency. The [CharacterRepository] collaborator is a *relaxed* MockK mock, so the "missing id"
* case needs no stubbing while the rest stub `getCharacterDetails` explicitly with `coEvery`; * case needs no stubbing while the rest stub `getCharacterDetails` explicitly with `coEvery`;
* assertions use AssertK; the back event is observed with Turbine. * assertions use AssertK; the back event is observed with Turbine.
@@ -85,7 +85,7 @@ class CharacterDetailViewModelTest {
advanceUntilIdle() advanceUntilIdle()
assertThat(viewModel.state.value.error).isNotNull() assertThat(viewModel.state.value.error).isNotNull()
// Same call, new answer the latest `coEvery` wins, so the retry attempt succeeds. // Same call, new answer - the latest `coEvery` wins, so the retry attempt succeeds.
coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1)) coEvery { repository.getCharacterDetails(1) } returns Result.Success(characterDetails(1))
viewModel.onAction(CharacterDetailAction.OnRetry) viewModel.onAction(CharacterDetailAction.OnRetry)
advanceUntilIdle() advanceUntilIdle()

View File

@@ -33,7 +33,7 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
/** /**
* Unit tests for [CharacterListViewModel] driven entirely through its public MVI surface * Unit tests for [CharacterListViewModel] - driven entirely through its public MVI surface
* (State/Action/Event), so they prove the VM correct regardless of which renderer hosts it. * (State/Action/Event), so they prove the VM correct regardless of which renderer hosts it.
* *
* Uses [StandardTestDispatcher] (not Unconfined) so launched work is queued until `advanceUntilIdle`, * Uses [StandardTestDispatcher] (not Unconfined) so launched work is queued until `advanceUntilIdle`,
@@ -159,7 +159,7 @@ class CharacterListViewModelTest {
viewModel.onAction(CharacterListAction.OnLoadNextPage) viewModel.onAction(CharacterListAction.OnLoadNextPage)
advanceUntilIdle() advanceUntilIdle()
// Only the single initial load ran the guarded next-page request never fired. // Only the single initial load ran - the guarded next-page request never fired.
coVerify(exactly = 1) { repository.getCharacters(1) } coVerify(exactly = 1) { repository.getCharacters(1) }
coVerify(exactly = 0) { repository.getCharacters(2) } coVerify(exactly = 0) { repository.getCharacters(2) }
} }

View File

@@ -3,7 +3,7 @@
agp = "9.0.1" agp = "9.0.1"
kotlin = "2.3.20" kotlin = "2.3.20"
# AndroidX core / lifecycle / activity / views # AndroidX - core / lifecycle / activity / views
androidxCore = "1.18.0" androidxCore = "1.18.0"
androidxLifecycle = "2.10.0" androidxLifecycle = "2.10.0"
androidxActivity = "1.13.0" androidxActivity = "1.13.0"
@@ -116,7 +116,7 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
# --- Testing --- # --- Testing ---
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitJupiter" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitJupiter" }
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junitJupiter" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junitJupiter" }
# Gradle 9 no longer bundles the launcher it must be on the test runtime classpath explicitly. # Gradle 9 no longer bundles the launcher - it must be on the test runtime classpath explicitly.
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatform" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatform" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" } assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" }