diff --git a/README.md b/README.md index fe2b166..17fd293 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,10 @@ Data comes from the no-key [Rick & Morty API](https://rickandmortyapi.com/). The 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**. -> **Status:** built milestone-by-milestone from the -> [Linear backlog](https://linear.app/adrian-kuta/project/android-architecture-showcase-b5ecdeddda6c). -> Foundation, Core Infrastructure, the flagship MVI feature, Breadth & Contrast, and Quality & Docs -> are complete; the project assembles green and ships unit + UI tests. The only optional item left is -> the Room offline-cache stretch (see [Optional: Room stretch](#optional-room-stretch)). +> **Status:** built milestone-by-milestone. Foundation, Core Infrastructure, the flagship MVI +> feature, Breadth & Contrast, and Quality & Docs are complete; the project assembles green and ships +> unit + UI tests. The only optional item left is 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) - [Build & run (`android` CLI)](#build--run-android-cli) - [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) | | Logging | Timber | | 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` > auto-applies the Kotlin Android plugin, so the convention plugins must **not** apply @@ -90,8 +88,6 @@ A key consequence: `:core:presentation`'s `UiText` is **Compose-free**, and the 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. -See **android-module-structure**. - --- ## The data → UI flow @@ -118,10 +114,9 @@ CharacterListScreen / CharacterListFragment (:presentation-compose / -views) ``` - **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 - **android-data-layer**, **android-data-layer-mappers**. + Mappers are pure extension functions in a `mappers/` package (`toDomain()`). - **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 @@ -133,7 +128,7 @@ rule it illustrates: > 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 -correct, and the contrast is the point. See **android-module-structure**, **android-di-koin**. +correct, and the contrast is the point. --- @@ -167,8 +162,6 @@ Views side with `repeatOnLifecycle`. 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. -See **android-presentation-mvi**, **android-compose-ui**. - --- ## One ViewModel, two renderers (Compose vs Views) @@ -191,8 +184,6 @@ from navigation. > the classic Views (e.g. `MaterialToolbar`, `?attr/colorOnSurfaceVariant`) require. A plain > `ComponentActivity` or a non-Material theme breaks the Fragment renderer. -See **android-compose-ui**, **android-module-structure**. - --- ## Errors: `Result`, `DataError`, `UiText` @@ -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 `ErrorState` + retry Action is what the real list and detail screens use. -See **android-error-handling**, **android-presentation-mvi**. - --- ## Navigation @@ -255,8 +244,6 @@ in `:app`. UI-agnostic `presentation` module needs **no** navigation dependency. The same `SavedStateHandle` also persists the list's loaded page across process death. -See **android-navigation**. - --- ## Dependency injection (Koin) @@ -285,26 +272,24 @@ The lambda form (`single { … }`) appears only where a constructor reference ca not a constructor). Compose roots inject with `koinViewModel()`; the Fragment uses `by viewModel()` — both resolve the **same** `CharacterListViewModel` class and supply its `SavedStateHandle`. -See **android-di-koin**, **koin-constructor-dsl**. - --- ## Testing -Tests prove the architecture, not just the code. Stack: **JUnit 5**, **Turbine** (Flow), **AssertK**, -`kotlinx-coroutines-test`, Ktor **`MockEngine`**, and Compose UI test. +Tests prove the architecture, not just the code. Stack: **JUnit 5**, **MockK**, **Turbine** (Flow), +**AssertK**, `kotlinx-coroutines-test`, Ktor **`MockEngine`**, and Compose UI test. | What | Where | Kind | |---|---|---| | `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` | | `CharacterListScreen` (robot) | `:feature:characters:presentation-compose` `src/androidTest` | instrumented Compose UI | Conventions demonstrated: -- **Fakes, not mocks.** `FakeCharacterRepository` is a real in-memory implementation with a - `failWith` toggle and call counts — tests assert against working behaviour, not recorded calls. +- **MockK for collaborators.** The ViewModel/UseCase tests stub the `CharacterRepository` interface + 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 @@ -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 possible but intentionally omitted (the VM logic is already covered by the shared unit tests). -See **android-testing**. - --- ## 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 -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 + `@Database` (prefer `autoMigrations`), then convert the repository to **offline-first** (`OfflineFirstCharacterRepository`: network → persist → expose a DB `Flow`; the ViewModel observes the DB, never the network response). The current `CharacterRepository` returning the `DataError` -supertype already anticipates a multi-source implementation. See **android-data-layer**. - ---- - -## 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 | +supertype already anticipates a multi-source implementation. diff --git a/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt index af4a3c8..0f77c00 100644 --- a/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt +++ b/core/data/src/main/kotlin/com/example/architecture/core/data/network/HttpClientExt.kt @@ -98,7 +98,7 @@ internal fun logNetworkError(throwable: Throwable, message: String) { 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 responseToResult( response: HttpResponse, ): Result { diff --git a/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListRobot.kt b/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListRobot.kt index a6e907c..35de864 100644 --- a/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListRobot.kt +++ b/feature/characters/presentation-compose/src/androidTest/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListRobot.kt @@ -14,7 +14,7 @@ import org.junit.Assert.assertTrue * Robot for [CharacterListScreen] UI tests. Each method returns `this` so calls read as a fluent * scenario (`robot.setContent(state).assertCharacterShown(...).clickCharacter(...)`). The robot owns * 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( private val composeRule: ComposeContentTestRule, diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoViewModel.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoViewModel.kt index a3764ec..7ca1a7f 100644 --- a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoViewModel.kt +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/ErrorDemoViewModel.kt @@ -27,7 +27,7 @@ import kotlinx.coroutines.launch * 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 * attempt (proving retry is an Action); [OnLoadSuccess] clears the error (proving it clears on - * success). See android-error-handling. + * success). */ class ErrorDemoViewModel : ViewModel() { diff --git a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt index 084191d..b03ddc8 100644 --- a/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt +++ b/feature/characters/presentation/src/main/kotlin/com/example/architecture/feature/characters/presentation/di/CharactersPresentationModule.kt @@ -11,7 +11,7 @@ import org.koin.dsl.module /** Presentation DI for the characters feature. Lives with the (UI-agnostic) ViewModels it provides. */ val charactersPresentationModule = module { // 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) viewModelOf(::CharacterListViewModel) viewModelOf(::CharacterDetailViewModel)