REDI-101: replace em/en dashes with hyphens in prose & comments
Em dashes are a common AI-writing tell; swap them (and en dashes) for plain hyphens across the README and all KDoc/comment prose so the repo reads as hand-authored. Byte-level replace of U+2014/U+2013 -> '-'; arrows and the ellipsis are left untouched. The two functional em dashes are intentionally kept: the `DASH = "—"` blank-field UI placeholder in CharacterDetailUi and the preview sample that mirrors it -- those are deliberate UX, not prose.
This commit is contained in:
64
README.md
64
README.md
@@ -1,7 +1,7 @@
|
||||
# Android Architecture Showcase
|
||||
|
||||
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*.
|
||||
|
||||
Data comes from the no-key [Rick & Morty API](https://rickandmortyapi.com/). The app lists
|
||||
@@ -81,12 +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` |
|
||||
| `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 |
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -98,19 +98,19 @@ One request flows through every layer, each with one job:
|
||||
Rick & Morty API
|
||||
│ JSON
|
||||
▼
|
||||
CharacterDto / CharactersResponseDto (:data/dto) – serialization shape
|
||||
│ CharacterMapper.toDomain() (:data/mappers) – DTO → domain, never the reverse leaks up
|
||||
CharacterDto / CharactersResponseDto (:data/dto) - serialization shape
|
||||
│ 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)
|
||||
│ GetCharactersPageUseCase(page) (:domain/usecase) – domain operation (see note)
|
||||
│ GetCharactersPageUseCase(page) (:domain/usecase) - domain operation (see note)
|
||||
▼
|
||||
CharacterListViewModel (:presentation) – holds State, processes Action, emits Event
|
||||
│ Character.toCharacterUi() (:presentation/model)– domain → UI model (display shaping)
|
||||
CharacterListViewModel (:presentation) - holds State, processes Action, emits Event
|
||||
│ 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.
|
||||
@@ -118,16 +118,16 @@ CharacterListScreen / CharacterListFragment (:presentation-compose / -views)
|
||||
- **UI models** (`*Ui`) live in `presentation` and carry display-ready data (e.g. blank detail fields
|
||||
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
|
||||
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
|
||||
> 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.
|
||||
|
||||
---
|
||||
@@ -140,39 +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 |
|
||||
| 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 |
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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).
|
||||
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
|
||||
separate (`StateFlow` vs `Channel`); the Compose side consumes events with `ObserveAsEvents`, the
|
||||
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
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
`@Stable` annotation (which would pull in compose-runtime). The exact same `CharacterListViewModel`
|
||||
(State/Action/Event/UI-model) is rendered twice:
|
||||
|
||||
- `:feature:characters:presentation-compose` — Jetpack Compose (`LazyColumn`).
|
||||
- `:feature:characters:presentation-views` — `Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`,
|
||||
- `:feature:characters:presentation-compose` - Jetpack Compose (`LazyColumn`).
|
||||
- `:feature:characters:presentation-views` - `Fragment` + ViewBinding + `RecyclerView`/`DiffUtil`,
|
||||
resolving the **same** Koin `CharacterListViewModel` via `by viewModel()`.
|
||||
|
||||
`:app` hosts the Views renderer inside the Compose `NavHost` via `AndroidFragment` (Compose↔View
|
||||
@@ -196,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`)
|
||||
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
|
||||
`SerializationException`). Upper layers never see raw exceptions.
|
||||
- The **presentation layer** maps a `DataError` to user-facing **`UiText`** via `DataError.toUiText()`
|
||||
@@ -237,10 +237,10 @@ in `:app`.
|
||||
- **Intra-feature** navigation (list → detail, list → error demo) is driven by the `NavController`
|
||||
passed into `charactersGraph(navController, …)`.
|
||||
- **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`
|
||||
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`
|
||||
also persists the list's loaded page across process death.
|
||||
|
||||
@@ -249,7 +249,7 @@ in `:app`.
|
||||
## Dependency injection (Koin)
|
||||
|
||||
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
|
||||
// :feature:characters:data
|
||||
@@ -268,8 +268,8 @@ val charactersPresentationModule = module {
|
||||
```
|
||||
|
||||
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,
|
||||
not a constructor). Compose roots inject with `koinViewModel()`; the Fragment uses `by viewModel()` —
|
||||
- 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()` -
|
||||
both resolve the **same** `CharacterListViewModel` class and supply its `SavedStateHandle`.
|
||||
|
||||
---
|
||||
@@ -289,7 +289,7 @@ Tests prove the architecture, not just the code. Stack: **JUnit 5**, **MockK**,
|
||||
Conventions demonstrated:
|
||||
|
||||
- **MockK for collaborators.** The ViewModel/UseCase tests stub the `CharacterRepository` interface
|
||||
with MockK — `coEvery` scripts the suspend calls, `coVerify` asserts the paging/retry interactions.
|
||||
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
|
||||
`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
|
||||
@@ -303,7 +303,7 @@ Conventions demonstrated:
|
||||
|
||||
> **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`
|
||||
> 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.
|
||||
|
||||
> **Espresso + API 34+:** Compose's test rule drives Espresso's `onIdle`, and transitive Espresso
|
||||
|
||||
Reference in New Issue
Block a user