Merge pull request #6 from AdrianKuta/feat/scrub-attribution
REDI-101: Remove AI/tooling attribution from docs & project
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
127
README.md
@@ -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 |
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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`.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()`.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user