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:
2026-06-10 16:54:02 +02:00
parent 2ae94e473d
commit 8f79608f5d
35 changed files with 79 additions and 79 deletions

View File

@@ -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