49
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Validate Gradle wrapper
|
||||||
|
uses: gradle/actions/wrapper-validation@v4
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Set up Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
|
- name: Unit tests (JUnit 5)
|
||||||
|
run: ./gradlew test --no-daemon --stacktrace
|
||||||
|
|
||||||
|
- name: Assemble (debug) + compile instrumented tests
|
||||||
|
# assembleDebugAndroidTest compiles the Compose UI test; it runs on a device via
|
||||||
|
# connectedDebugAndroidTest (locally / on an emulator runner), not in this build job.
|
||||||
|
run: ./gradlew assembleDebug assembleDebugAndroidTest --no-daemon --stacktrace
|
||||||
|
|
||||||
|
- name: Upload test reports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-reports
|
||||||
|
path: '**/build/reports/tests/'
|
||||||
|
if-no-files-found: ignore
|
||||||
20
.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
**/.gradle/
|
||||||
|
**/build/
|
||||||
|
/captures
|
||||||
|
|
||||||
|
# Kotlin
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
|
||||||
|
# Local config / secrets
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# IDE (JetBrains / Android Studio) - fully ignored to avoid machine-specific churn
|
||||||
|
/.idea/
|
||||||
353
README.md
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
# 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.
|
||||||
|
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
|
||||||
|
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. 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)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
- [Stack](#stack)
|
||||||
|
- [Module structure & dependency rules](#module-structure--dependency-rules)
|
||||||
|
- [The data → UI flow](#the-data--ui-flow)
|
||||||
|
- [Presentation patterns: MVI vs MVVM](#presentation-patterns-mvi-vs-mvvm)
|
||||||
|
- [One ViewModel, two renderers (Compose vs Views)](#one-viewmodel-two-renderers-compose-vs-views)
|
||||||
|
- [Errors: `Result`, `DataError`, `UiText`](#errors-result-dataerror-uitext)
|
||||||
|
- [Navigation](#navigation)
|
||||||
|
- [Dependency injection (Koin)](#dependency-injection-koin)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Build & run (`android` CLI)](#build--run-android-cli)
|
||||||
|
- [Optional: Room stretch](#optional-room-stretch)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Concern | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Build | Multi-module Gradle + `:build-logic` **convention plugins**; a single **version catalog** (`gradle/libs.versions.toml`) is the only place versions live |
|
||||||
|
| Toolchain | AGP 9.0.1, Kotlin 2.3.20, Gradle 9.1, `compileSdk`/`targetSdk` 36, `minSdk` 24, Java 17 |
|
||||||
|
| UI | Jetpack Compose (Material 3) + one classic **Views/XML** renderer |
|
||||||
|
| DI | Koin 4.1 (constructor DSL) |
|
||||||
|
| Networking | Ktor (OkHttp engine) + KotlinX Serialization |
|
||||||
|
| Images | Coil 3 |
|
||||||
|
| Navigation | type-safe Compose Navigation (`@Serializable` routes) |
|
||||||
|
| Logging | Timber |
|
||||||
|
| Async | Coroutines + Flow |
|
||||||
|
| 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
|
||||||
|
> `org.jetbrains.kotlin.android` themselves. Source lives in `src/main/kotlin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module structure & dependency rules
|
||||||
|
|
||||||
|
Modularized **by feature first, then by layer** (Clean Architecture: `presentation → domain ← data`).
|
||||||
|
Features never depend on each other; anything shared moves to a `core` module; `:app` wires the graph.
|
||||||
|
|
||||||
|
```
|
||||||
|
:app → wires everything; single Activity, Compose host, Koin start
|
||||||
|
:build-logic → Gradle convention plugins (the only place build config lives)
|
||||||
|
|
||||||
|
:core:domain → Result / Error / DataError, shared contracts (pure Kotlin)
|
||||||
|
:core:data → Ktor HttpClient factory + safe-call helpers (BuildConfig.BASE_URL)
|
||||||
|
:core:presentation → UiText, ObserveAsEvents, DataError → UiText
|
||||||
|
:core:design-system → AppTheme + reusable composables (AppScaffold, ErrorState, …)
|
||||||
|
|
||||||
|
:feature:characters:domain → models, CharacterRepository, GetCharactersPageUseCase (pure Kotlin)
|
||||||
|
:feature:characters:data → DTOs, mappers, KtorCharacterDataSource, NetworkCharacterRepository
|
||||||
|
:feature:characters:presentation → MVI ViewModels/State/Action/Event (UI-agnostic: no Compose, no Views)
|
||||||
|
:feature:characters:presentation-compose → Compose renderer (list, detail, error demo, nav graph)
|
||||||
|
:feature:characters:presentation-views → Views/XML renderer of the list (same ViewModel)
|
||||||
|
|
||||||
|
:feature:about:presentation → MVVM contrast screen
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dependency rules** (enforced by what each convention plugin exposes):
|
||||||
|
|
||||||
|
| Layer | May depend on |
|
||||||
|
|---|---|
|
||||||
|
| `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` |
|
||||||
|
| `: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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The data → UI flow
|
||||||
|
|
||||||
|
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
|
||||||
|
▼
|
||||||
|
Character / CharactersPage (:domain/model) - pure Kotlin domain model
|
||||||
|
│ CharacterRepository.getCharacters() (:domain contract, :data impl)
|
||||||
|
│ 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)
|
||||||
|
▼
|
||||||
|
CharacterUi in CharacterListState (:presentation) - immutable UI state
|
||||||
|
▼
|
||||||
|
CharacterListScreen / CharacterListFragment (:presentation-compose / -views) - dumb renderers
|
||||||
|
```
|
||||||
|
|
||||||
|
- **DTOs** (`*Dto`) live in `data`; **domain models** are separate and never become DTOs/entities.
|
||||||
|
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).
|
||||||
|
|
||||||
|
### 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
|
||||||
|
> 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
|
||||||
|
correct, and the contrast is the point.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Presentation patterns: MVI vs MVVM
|
||||||
|
|
||||||
|
Both patterns live side by side so the trade-off is concrete.
|
||||||
|
|
||||||
|
| | **MVI** (`:feature:characters:*`) | **MVVM** (`:feature:about:presentation`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 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` |
|
||||||
|
| 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
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One ViewModel, two renderers (Compose vs Views)
|
||||||
|
|
||||||
|
`: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`,
|
||||||
|
resolving the **same** Koin `CharacterListViewModel` via `by viewModel()`.
|
||||||
|
|
||||||
|
`:app` hosts the Views renderer inside the Compose `NavHost` via `AndroidFragment` (Compose↔View
|
||||||
|
interop) and injects all navigation as callbacks, so the renderers stay decoupled from each other and
|
||||||
|
from navigation.
|
||||||
|
|
||||||
|
> **Material3-XML-theme gotcha:** the host Activity (`MainActivity`) extends **`FragmentActivity`**
|
||||||
|
> (so `AndroidFragment` has a `FragmentManager`) and uses a **Material Components XML theme**, which
|
||||||
|
> the classic Views (e.g. `MaterialToolbar`, `?attr/colorOnSurfaceVariant`) require. A plain
|
||||||
|
> `ComponentActivity` or a non-Material theme breaks the Fragment renderer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Errors: `Result`, `DataError`, `UiText`
|
||||||
|
|
||||||
|
Expected failures are **values, not exceptions**. The whole app speaks one typed result:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
sealed interface Result<out D, out E : Error> { Success(data) ; Error(error) } // :core:domain
|
||||||
|
sealed interface DataError : Error { enum Network { NO_INTERNET, NOT_FOUND, SERVER_ERROR, SERIALIZATION, … } ; enum Local { … } }
|
||||||
|
```
|
||||||
|
|
||||||
|
- 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
|
||||||
|
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()`
|
||||||
|
(`:core:presentation`). `UiText` is itself Compose-free (a `StringResource`/`DynamicString`), so a
|
||||||
|
UI-agnostic ViewModel can hold `UiText?` in state; the renderer resolves it (`asString()` in
|
||||||
|
Compose, `asString(context)` in Views).
|
||||||
|
|
||||||
|
### The error-handling demo (overflow menu → "Error handling demo")
|
||||||
|
|
||||||
|
A runnable walk-through of the whole pipeline. Pick a failure to force; the ViewModel produces the
|
||||||
|
real `DataError.Network`, routes it through the **same** steps a genuine call uses, and the shared
|
||||||
|
design-system `ErrorState` renders it:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Force: No internet] → Result.Error(DataError.Network.NO_INTERNET)
|
||||||
|
→ onFailure { … }
|
||||||
|
→ DataError.toUiText() = UiText.StringResource(R.string.error_no_internet)
|
||||||
|
→ ErrorState(message = uiText.asString(), onRetry = { onAction(OnRetry) })
|
||||||
|
```
|
||||||
|
|
||||||
|
Three distinct cases (`NO_INTERNET`, `NOT_FOUND`, `SERVER_ERROR`) each render their mapped message;
|
||||||
|
**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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
Type-safe Compose Navigation with `@Serializable` route objects, one nav graph per feature, assembled
|
||||||
|
in `:app`.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Serializable data object CharacterListRoute
|
||||||
|
@Serializable data class CharacterDetailRoute(val characterId: Int)
|
||||||
|
@Serializable data object ErrorDemoRoute
|
||||||
|
```
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **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
|
||||||
|
UI-agnostic `presentation` module needs **no** navigation dependency. The same `SavedStateHandle`
|
||||||
|
also persists the list's loaded page across process death.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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**:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// :feature:characters:data
|
||||||
|
val charactersDataModule = module {
|
||||||
|
singleOf(::KtorCharacterDataSource)
|
||||||
|
singleOf(::NetworkCharacterRepository) { bind<CharacterRepository>() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// :feature:characters:presentation
|
||||||
|
val charactersPresentationModule = module {
|
||||||
|
factoryOf(::GetCharactersPageUseCase) // stateless UseCase
|
||||||
|
viewModelOf(::CharacterListViewModel) // same VM used by both renderers
|
||||||
|
viewModelOf(::CharacterDetailViewModel)
|
||||||
|
viewModelOf(::ErrorDemoViewModel)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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()` -
|
||||||
|
both resolve the **same** `CharacterListViewModel` class and supply its `SavedStateHandle`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
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, 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:
|
||||||
|
|
||||||
|
- **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
|
||||||
|
rapid-duplicate-paging guard (which is why these use `StandardTestDispatcher`).
|
||||||
|
- **Repository tested over a real Ktor client** with a swapped `MockEngine`
|
||||||
|
(`HttpClientFactory.create(engine)`): success mapping, `404 → NOT_FOUND`, `500 → SERVER_ERROR`,
|
||||||
|
malformed body `→ SERIALIZATION`.
|
||||||
|
- **Robot pattern** for the Compose UI test: `CharacterListRobot` methods `return this` so a test
|
||||||
|
reads as a scenario; it asserts a rendered item, the empty/error states, and that a tap fires the
|
||||||
|
right `Action`.
|
||||||
|
|
||||||
|
> **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
|
||||||
|
> `junit-platform-launcher`, which Gradle 9 no longer bundles.
|
||||||
|
|
||||||
|
> **Espresso + API 34+:** Compose's test rule drives Espresso's `onIdle`, and transitive Espresso
|
||||||
|
> 3.5.0 calls the removed `InputManager.getInstance()`. The catalog pins espresso/runner to current
|
||||||
|
> versions in the `compose-ui-test` bundle to fix that.
|
||||||
|
|
||||||
|
What runs where: `./gradlew test` (all JVM unit tests) runs in **CI**; the instrumented Compose test
|
||||||
|
runs on a device/emulator via `./gradlew :feature:characters:presentation-compose:connectedDebugAndroidTest`
|
||||||
|
(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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & run (`android` CLI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
./gradlew assembleDebug # build the debug APK
|
||||||
|
./gradlew projects # print the module tree
|
||||||
|
./gradlew test # all JVM unit tests (JUnit 5)
|
||||||
|
./gradlew :feature:characters:presentation-compose:connectedDebugAndroidTest # Compose UI test (needs a device)
|
||||||
|
```
|
||||||
|
|
||||||
|
Using the `android` CLI for an emulator + run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
android emulator list # list AVDs
|
||||||
|
android emulator start <avd-name> # boot an emulator (returns when ready)
|
||||||
|
android run # build & deploy the app
|
||||||
|
android screenshot -o screen.png # capture the current screen
|
||||||
|
android layout --pretty # dump the UI tree (faster than a screenshot for debugging)
|
||||||
|
android docs search "<topic>" # search authoritative Android docs
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires JDK 17+ (the Gradle build pins a Java 17 toolchain) and the Android SDK
|
||||||
|
(`compileSdk 36`, `minSdk 24`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional: Room stretch
|
||||||
|
|
||||||
|
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.
|
||||||
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
45
app/build.gradle.kts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.architecture.android.application)
|
||||||
|
alias(libs.plugins.architecture.compose)
|
||||||
|
alias(libs.plugins.architecture.koin)
|
||||||
|
// For the @Serializable CharactersViewsRoute (Compose↔View interop destination).
|
||||||
|
alias(libs.plugins.architecture.kotlinx.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
// Needed for BuildConfig.DEBUG (gating the Timber DebugTree).
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// :app is the only place modules are assembled and the dependency graph is wired.
|
||||||
|
implementation(project(":core:data"))
|
||||||
|
implementation(project(":core:design-system"))
|
||||||
|
|
||||||
|
// Characters feature: data + presentation (Koin modules) + both renderers (Compose nav graph,
|
||||||
|
// Views Fragment hosted via interop).
|
||||||
|
implementation(project(":feature:characters:data"))
|
||||||
|
implementation(project(":feature:characters:presentation"))
|
||||||
|
implementation(project(":feature:characters:presentation-compose"))
|
||||||
|
implementation(project(":feature:characters:presentation-views"))
|
||||||
|
|
||||||
|
// About feature (MVVM contrast).
|
||||||
|
implementation(project(":feature:about:presentation"))
|
||||||
|
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.bundles.lifecycle.compose)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
// Compose↔View interop: hosts a Fragment inside the Compose NavHost.
|
||||||
|
implementation(libs.androidx.fragment.compose)
|
||||||
|
// Material Components - required for the Material3 XML Activity theme.
|
||||||
|
implementation(libs.material)
|
||||||
|
// Logging - the DebugTree is planted here; other modules log via Timber's static API.
|
||||||
|
implementation(libs.timber)
|
||||||
|
|
||||||
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
}
|
||||||
2
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Add project-specific ProGuard rules here.
|
||||||
|
# Minification is disabled for the release build type in this teaching project.
|
||||||
23
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".ArchitectureApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:theme="@style/Theme.AndroidArchitectureShowcase">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.example.architecture
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.example.architecture.core.data.di.coreDataModule
|
||||||
|
import com.example.architecture.feature.about.presentation.di.aboutPresentationModule
|
||||||
|
import com.example.architecture.feature.characters.data.di.charactersDataModule
|
||||||
|
import com.example.architecture.feature.characters.presentation.di.charactersPresentationModule
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.android.ext.koin.androidLogger
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single Koin entry point. Every feature's `*DataModule` / `*PresentationModule` is assembled here,
|
||||||
|
* never inside feature modules.
|
||||||
|
*/
|
||||||
|
class ArchitectureApp : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
// Plant Timber only in debug; release builds get no logs (swap in a crash-reporting tree).
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Timber.plant(Timber.DebugTree())
|
||||||
|
}
|
||||||
|
|
||||||
|
startKoin {
|
||||||
|
androidLogger()
|
||||||
|
androidContext(this@ArchitectureApp)
|
||||||
|
modules(
|
||||||
|
// core
|
||||||
|
coreDataModule,
|
||||||
|
// characters feature
|
||||||
|
charactersDataModule,
|
||||||
|
charactersPresentationModule,
|
||||||
|
// about feature (MVVM contrast)
|
||||||
|
aboutPresentationModule,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.architecture
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* stays navigation-agnostic (it knows nothing about Compose Navigation or this route).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data object CharactersViewsRoute
|
||||||
60
app/src/main/kotlin/com/example/architecture/MainActivity.kt
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package com.example.architecture
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.fragment.compose.AndroidFragment
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
import com.example.architecture.feature.about.presentation.AboutRoute
|
||||||
|
import com.example.architecture.feature.about.presentation.aboutGraph
|
||||||
|
import com.example.architecture.feature.characters.presentation.compose.CharacterDetailRoute
|
||||||
|
import com.example.architecture.feature.characters.presentation.compose.CharacterListRoute
|
||||||
|
import com.example.architecture.feature.characters.presentation.compose.charactersGraph
|
||||||
|
import com.example.architecture.feature.characters.presentation.views.CharacterListFragment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts the single Compose NavHost and owns every cross-feature / cross-toolkit wiring:
|
||||||
|
* - the characters graph (Compose list + detail),
|
||||||
|
* - the About graph (MVVM contrast),
|
||||||
|
* - the Views renderer embedded via [AndroidFragment] (Compose↔View interop).
|
||||||
|
*
|
||||||
|
* Extends [FragmentActivity] (not plain ComponentActivity) so [AndroidFragment] has a FragmentManager.
|
||||||
|
*/
|
||||||
|
class MainActivity : FragmentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
AppTheme {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = CharacterListRoute,
|
||||||
|
) {
|
||||||
|
charactersGraph(
|
||||||
|
navController = navController,
|
||||||
|
onOpenAbout = { navController.navigate(AboutRoute) },
|
||||||
|
onOpenViewsList = { navController.navigate(CharactersViewsRoute) },
|
||||||
|
)
|
||||||
|
aboutGraph(
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
// Compose↔View interop: the same characters list, rendered by a Fragment. :app
|
||||||
|
// injects the navigation callbacks so the Views module stays nav-agnostic.
|
||||||
|
composable<CharactersViewsRoute> {
|
||||||
|
AndroidFragment<CharacterListFragment> { fragment ->
|
||||||
|
fragment.onCharacterClick = { id ->
|
||||||
|
navController.navigate(CharacterDetailRoute(id))
|
||||||
|
}
|
||||||
|
fragment.onNavigateBack = { navController.popBackStack() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
3
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Android Architecture Showcase</string>
|
||||||
|
</resources>
|
||||||
9
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!--
|
||||||
|
Compose drives the in-app theming via AppTheme (core:design-system); this XML theme styles
|
||||||
|
the Activity window. It uses a Material3 parent so the Views renderer hosted later (via
|
||||||
|
Compose<->View interop) inherits Material3 styling.
|
||||||
|
-->
|
||||||
|
<style name="Theme.AndroidArchitectureShowcase" parent="Theme.Material3.DayNight.NoActionBar" />
|
||||||
|
</resources>
|
||||||
13
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample backup rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/guide/topics/data/autobackup
|
||||||
|
for details.
|
||||||
|
Note: This file is ignored for devices older than API 31
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<!--
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
-->
|
||||||
|
</full-backup-content>
|
||||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample data extraction rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||||
|
for details.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
-->
|
||||||
|
</cloud-backup>
|
||||||
|
<!--
|
||||||
|
<device-transfer>
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
</device-transfer>
|
||||||
|
-->
|
||||||
|
</data-extraction-rules>
|
||||||
71
build-logic/convention/build.gradle.kts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
`kotlin-dsl`
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "com.example.architecture.buildlogic"
|
||||||
|
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<KotlinCompile>().configureEach {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// The convention plugins apply these by id, so they only need them at compile time.
|
||||||
|
compileOnly(libs.android.gradlePlugin)
|
||||||
|
compileOnly(libs.kotlin.gradlePlugin)
|
||||||
|
compileOnly(libs.compose.compiler.gradlePlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
gradlePlugin {
|
||||||
|
plugins {
|
||||||
|
register("androidApplication") {
|
||||||
|
id = "architecture.android.application"
|
||||||
|
implementationClass = "com.example.architecture.convention.AndroidApplicationConventionPlugin"
|
||||||
|
}
|
||||||
|
register("androidLibrary") {
|
||||||
|
id = "architecture.android.library"
|
||||||
|
implementationClass = "com.example.architecture.convention.AndroidLibraryConventionPlugin"
|
||||||
|
}
|
||||||
|
register("androidFeature") {
|
||||||
|
id = "architecture.android.feature"
|
||||||
|
implementationClass = "com.example.architecture.convention.AndroidFeatureConventionPlugin"
|
||||||
|
}
|
||||||
|
register("androidFeatureViews") {
|
||||||
|
id = "architecture.android.feature.views"
|
||||||
|
implementationClass = "com.example.architecture.convention.AndroidFeatureViewsConventionPlugin"
|
||||||
|
}
|
||||||
|
register("domainModule") {
|
||||||
|
id = "architecture.domain.module"
|
||||||
|
implementationClass = "com.example.architecture.convention.DomainModuleConventionPlugin"
|
||||||
|
}
|
||||||
|
register("androidUnitTest") {
|
||||||
|
id = "architecture.android.unit.test"
|
||||||
|
implementationClass = "com.example.architecture.convention.AndroidUnitTestConventionPlugin"
|
||||||
|
}
|
||||||
|
register("compose") {
|
||||||
|
id = "architecture.compose"
|
||||||
|
implementationClass = "com.example.architecture.convention.ComposeConventionPlugin"
|
||||||
|
}
|
||||||
|
register("koin") {
|
||||||
|
id = "architecture.koin"
|
||||||
|
implementationClass = "com.example.architecture.convention.KoinConventionPlugin"
|
||||||
|
}
|
||||||
|
register("ktor") {
|
||||||
|
id = "architecture.ktor"
|
||||||
|
implementationClass = "com.example.architecture.convention.KtorConventionPlugin"
|
||||||
|
}
|
||||||
|
register("kotlinxSerialization") {
|
||||||
|
id = "architecture.kotlinx.serialization"
|
||||||
|
implementationClass = "com.example.architecture.convention.KotlinxSerializationConventionPlugin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.example.architecture.convention
|
||||||
|
|
||||||
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
|
import org.gradle.api.JavaVersion
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures the single `:app` module: applicationId, SDK levels, Java 17, and the release
|
||||||
|
* build type. Compose is added separately by [ComposeConventionPlugin].
|
||||||
|
*/
|
||||||
|
class AndroidApplicationConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
pluginManager.apply("com.android.application")
|
||||||
|
|
||||||
|
extensions.configure<ApplicationExtension> {
|
||||||
|
namespace = "com.example.architecture"
|
||||||
|
compileSdk = COMPILE_SDK
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.example.architecture"
|
||||||
|
minSdk = MIN_SDK
|
||||||
|
targetSdk = TARGET_SDK
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
getByName("release") {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = false
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configureKotlinJvmToolchain()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.example.architecture.convention
|
||||||
|
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Compose-based feature presentation module: Android library + Compose + Koin, plus the common
|
||||||
|
* feature stack (lifecycle, type-safe navigation, coroutines, Coil). Used by every
|
||||||
|
* `:feature:*:presentation-compose` and the MVVM `:feature:about:presentation`.
|
||||||
|
*/
|
||||||
|
class AndroidFeatureConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
pluginManager.apply("architecture.android.library")
|
||||||
|
pluginManager.apply("architecture.compose")
|
||||||
|
pluginManager.apply("architecture.koin")
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
add("implementation", libs.findLibrary("androidx-core-ktx").get())
|
||||||
|
add("implementation", libs.findBundle("lifecycle-compose").get())
|
||||||
|
add("implementation", libs.findLibrary("androidx-navigation-compose").get())
|
||||||
|
add("implementation", libs.findLibrary("kotlinx-coroutines-android").get())
|
||||||
|
add("implementation", libs.findLibrary("koin-androidx-compose").get())
|
||||||
|
add("implementation", libs.findLibrary("coil-compose").get())
|
||||||
|
add("implementation", libs.findLibrary("coil-network-okhttp").get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.example.architecture.convention
|
||||||
|
|
||||||
|
import com.android.build.api.dsl.LibraryExtension
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A classic Views feature renderer: Android library + Koin, ViewBinding ON, Compose OFF.
|
||||||
|
* Brings Fragment / RecyclerView / Material / AppCompat and Coil's ImageView loader so the
|
||||||
|
* Views renderer can drive the same ViewModel as the Compose one.
|
||||||
|
*/
|
||||||
|
class AndroidFeatureViewsConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
pluginManager.apply("architecture.android.library")
|
||||||
|
pluginManager.apply("architecture.koin")
|
||||||
|
|
||||||
|
extensions.configure<LibraryExtension> {
|
||||||
|
buildFeatures.viewBinding = true
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
add("implementation", libs.findLibrary("androidx-core-ktx").get())
|
||||||
|
add("implementation", libs.findBundle("views").get())
|
||||||
|
add("implementation", libs.findLibrary("androidx-lifecycle-runtime-ktx").get())
|
||||||
|
add("implementation", libs.findLibrary("androidx-lifecycle-viewmodel-ktx").get())
|
||||||
|
add("implementation", libs.findLibrary("kotlinx-coroutines-android").get())
|
||||||
|
add("implementation", libs.findLibrary("coil-core").get())
|
||||||
|
add("implementation", libs.findLibrary("coil-network-okhttp").get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.example.architecture.convention
|
||||||
|
|
||||||
|
import com.android.build.api.dsl.LibraryExtension
|
||||||
|
import org.gradle.api.JavaVersion
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base configuration shared by every Android library module. Each module still declares its own
|
||||||
|
* `namespace` in its build file.
|
||||||
|
*/
|
||||||
|
class AndroidLibraryConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
pluginManager.apply("com.android.library")
|
||||||
|
|
||||||
|
extensions.configure<LibraryExtension> {
|
||||||
|
compileSdk = COMPILE_SDK
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = MIN_SDK
|
||||||
|
// Used by instrumented (androidTest) tests, e.g. the Compose UI test in
|
||||||
|
// :feature:characters:presentation-compose. Harmless for modules without androidTest.
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configureKotlinJvmToolchain()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.example.architecture.convention
|
||||||
|
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.api.tasks.testing.Test
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
import org.gradle.kotlin.dsl.withType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an Android library module's local unit tests (`src/test`) on the **JUnit 5 platform** with the
|
||||||
|
* 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
|
||||||
|
* 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
|
||||||
|
* `useJUnitPlatform()` on it is enough (this mirrors `DomainModuleConventionPlugin`, which does the
|
||||||
|
* same for pure-JVM modules).
|
||||||
|
*/
|
||||||
|
class AndroidUnitTestConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
dependencies {
|
||||||
|
add("testImplementation", libs.findBundle("unit-test").get())
|
||||||
|
add("testRuntimeOnly", libs.findLibrary("junit-jupiter-engine").get())
|
||||||
|
// Gradle 9 dropped the bundled launcher; JUnit 5 won't start without it.
|
||||||
|
add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get())
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.example.architecture.convention
|
||||||
|
|
||||||
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
|
import com.android.build.api.dsl.LibraryExtension
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables Jetpack Compose on an Android application or library module: applies the Compose
|
||||||
|
* compiler plugin, turns on the `compose` build feature, and wires the BOM-aligned Compose deps.
|
||||||
|
*
|
||||||
|
* Order-independent: `withPlugin` enables the build feature whenever the Android plugin is applied,
|
||||||
|
* regardless of whether this plugin runs before or after it.
|
||||||
|
*/
|
||||||
|
class ComposeConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
|
||||||
|
pluginManager.withPlugin("com.android.library") {
|
||||||
|
extensions.configure<LibraryExtension> { buildFeatures.compose = true }
|
||||||
|
}
|
||||||
|
pluginManager.withPlugin("com.android.application") {
|
||||||
|
extensions.configure<ApplicationExtension> { buildFeatures.compose = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// `implementation` (not api): every Compose consumer applies this convention itself, so
|
||||||
|
// Compose must NOT leak transitively - that keeps the UI-agnostic presentation module
|
||||||
|
// (which depends on core:presentation) free of Compose.
|
||||||
|
val bom = platform(libs.findLibrary("androidx-compose-bom").get())
|
||||||
|
add("implementation", bom)
|
||||||
|
add("androidTestImplementation", bom)
|
||||||
|
add("implementation", libs.findBundle("compose").get())
|
||||||
|
add("debugImplementation", libs.findLibrary("androidx-compose-ui-tooling").get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.example.architecture.convention
|
||||||
|
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.api.tasks.testing.Test
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
import org.gradle.kotlin.dsl.withType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-Kotlin (JVM) module for the domain layer: no Android dependencies. Adds Coroutines (for
|
||||||
|
* `Flow`-returning repository interfaces) and runs unit tests on the JUnit 5 platform.
|
||||||
|
*/
|
||||||
|
class DomainModuleConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
pluginManager.apply("org.jetbrains.kotlin.jvm")
|
||||||
|
|
||||||
|
configureKotlinJvmToolchain()
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
add("implementation", libs.findLibrary("kotlinx-coroutines-core").get())
|
||||||
|
add("testImplementation", libs.findLibrary("junit-jupiter-api").get())
|
||||||
|
add("testImplementation", libs.findLibrary("assertk").get())
|
||||||
|
// Domain doesn't consume the `unit-test` bundle, so MockK and the coroutines
|
||||||
|
// test artifact are added explicitly here.
|
||||||
|
add("testImplementation", libs.findLibrary("mockk").get())
|
||||||
|
add("testImplementation", libs.findLibrary("kotlinx-coroutines-test").get())
|
||||||
|
add("testRuntimeOnly", libs.findLibrary("junit-jupiter-engine").get())
|
||||||
|
// Gradle 9 dropped the bundled launcher; JUnit 5 won't start without it.
|
||||||
|
add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get())
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.example.architecture.convention
|
||||||
|
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the Koin BOM + core/android dependencies. Compose-specific Koin (`koin-androidx-compose`)
|
||||||
|
* is added only by [AndroidFeatureConventionPlugin] so UI-agnostic modules stay Compose-free.
|
||||||
|
*/
|
||||||
|
class KoinConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
dependencies {
|
||||||
|
val bom = platform(libs.findLibrary("koin-bom").get())
|
||||||
|
add("implementation", bom)
|
||||||
|
add("implementation", libs.findBundle("koin").get())
|
||||||
|
add("testImplementation", libs.findLibrary("koin-test").get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.example.architecture.convention
|
||||||
|
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the KotlinX Serialization compiler plugin + JSON runtime for modules that hold
|
||||||
|
* `@Serializable` DTOs or navigation routes but do not need the full Ktor stack.
|
||||||
|
*/
|
||||||
|
class KotlinxSerializationConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
pluginManager.apply("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
add("implementation", libs.findLibrary("kotlinx-serialization-json").get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.example.architecture.convention
|
||||||
|
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the Ktor client bundle (OkHttp engine, content negotiation, JSON, logging) and the
|
||||||
|
* KotlinX Serialization runtime. Applies the serialization compiler plugin so `@Serializable`
|
||||||
|
* DTOs compile.
|
||||||
|
*/
|
||||||
|
class KtorConventionPlugin : Plugin<Project> {
|
||||||
|
override fun apply(target: Project) = with(target) {
|
||||||
|
pluginManager.apply("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
add("implementation", libs.findBundle("ktor").get())
|
||||||
|
add("implementation", libs.findLibrary("kotlinx-serialization-json").get())
|
||||||
|
add("testImplementation", libs.findLibrary("ktor-client-mock").get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.example.architecture.convention
|
||||||
|
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.api.artifacts.VersionCatalog
|
||||||
|
import org.gradle.api.artifacts.VersionCatalogsExtension
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
import org.gradle.kotlin.dsl.getByType
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension
|
||||||
|
|
||||||
|
internal const val COMPILE_SDK = 36
|
||||||
|
internal const val MIN_SDK = 24
|
||||||
|
internal const val TARGET_SDK = 36
|
||||||
|
internal const val JVM_TARGET = 17
|
||||||
|
|
||||||
|
/** Type-safe accessor for the shared `libs` version catalog from inside a convention plugin. */
|
||||||
|
internal val Project.libs: VersionCatalog
|
||||||
|
get() = extensions.getByType<VersionCatalogsExtension>().named("libs")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the Kotlin JVM toolchain. Works for both Android modules and pure-Kotlin (`jvm`) modules
|
||||||
|
* because [KotlinProjectExtension] is the common supertype of both kotlin extensions.
|
||||||
|
*/
|
||||||
|
internal fun Project.configureKotlinJvmToolchain() {
|
||||||
|
extensions.configure<KotlinProjectExtension> {
|
||||||
|
jvmToolchain(JVM_TARGET)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
build-logic/settings.gradle.kts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
@file:Suppress("UnstableApiUsage")
|
||||||
|
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
versionCatalogs {
|
||||||
|
// Reuse the single source of truth for versions.
|
||||||
|
create("libs") {
|
||||||
|
from(files("../gradle/libs.versions.toml"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "build-logic"
|
||||||
|
include(":convention")
|
||||||
9
build.gradle.kts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Top-level build file. Plugins are declared here `apply false` so their markers are
|
||||||
|
// on the classpath and the :build-logic convention plugins can apply them by id.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.android.library) apply false
|
||||||
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
|
alias(libs.plugins.compose.compiler) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
}
|
||||||
25
core/data/build.gradle.kts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.architecture.android.library)
|
||||||
|
alias(libs.plugins.architecture.ktor)
|
||||||
|
alias(libs.plugins.architecture.koin)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.architecture.core.data"
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
defaultConfig {
|
||||||
|
// The no-key Rick & Morty API. constructRoute() reads this BuildConfig field.
|
||||||
|
buildConfigField("String", "BASE_URL", "\"https://rickandmortyapi.com/api\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:domain"))
|
||||||
|
implementation(libs.timber)
|
||||||
|
// `api`: the public inline HttpClient.get/post/delete helpers are inlined into consumer modules,
|
||||||
|
// so those modules need the Ktor request/response types on their compile classpath.
|
||||||
|
api(libs.ktor.client.core)
|
||||||
|
}
|
||||||
7
core/data/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Networking lives in this module, so the permission is declared here and merges into :app. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.example.architecture.core.data.di
|
||||||
|
|
||||||
|
import com.example.architecture.core.data.network.HttpClientFactory
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* constructor DSL (`singleOf`) cannot express it. Feature data modules append their own bindings.
|
||||||
|
*/
|
||||||
|
val coreDataModule = module {
|
||||||
|
single<HttpClient> { HttpClientFactory.create(OkHttp.create()) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.example.architecture.core.data.network
|
||||||
|
|
||||||
|
import com.example.architecture.core.data.BuildConfig
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.delete
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.request.url
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.nio.channels.UnresolvedAddressException
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
suspend inline fun <reified Response : Any> HttpClient.get(
|
||||||
|
route: String,
|
||||||
|
queryParameters: Map<String, Any?> = emptyMap(),
|
||||||
|
): Result<Response, DataError.Network> {
|
||||||
|
return safeCall {
|
||||||
|
get {
|
||||||
|
url(constructRoute(route))
|
||||||
|
queryParameters.forEach { (key, value) -> parameter(key, value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <reified Request, reified Response : Any> HttpClient.post(
|
||||||
|
route: String,
|
||||||
|
body: Request,
|
||||||
|
): Result<Response, DataError.Network> {
|
||||||
|
return safeCall {
|
||||||
|
post {
|
||||||
|
url(constructRoute(route))
|
||||||
|
setBody(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <reified Response : Any> HttpClient.delete(
|
||||||
|
route: String,
|
||||||
|
queryParameters: Map<String, Any?> = emptyMap(),
|
||||||
|
): Result<Response, DataError.Network> {
|
||||||
|
return safeCall {
|
||||||
|
delete {
|
||||||
|
url(constructRoute(route))
|
||||||
|
queryParameters.forEach { (key, value) -> parameter(key, value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a Ktor call AND its response deserialization, turning transport/parse exceptions into typed
|
||||||
|
* [DataError.Network] results. `responseToResult` runs inside the try so a malformed 2xx body maps
|
||||||
|
* to SERIALIZATION instead of escaping uncaught.
|
||||||
|
*/
|
||||||
|
suspend inline fun <reified T> safeCall(
|
||||||
|
execute: () -> HttpResponse,
|
||||||
|
): Result<T, DataError.Network> {
|
||||||
|
return try {
|
||||||
|
responseToResult(execute())
|
||||||
|
} catch (e: UnresolvedAddressException) {
|
||||||
|
logNetworkError(e, "No internet (unresolved address)")
|
||||||
|
Result.Error(DataError.Network.NO_INTERNET)
|
||||||
|
} catch (e: UnknownHostException) {
|
||||||
|
logNetworkError(e, "No internet (unknown host)")
|
||||||
|
Result.Error(DataError.Network.NO_INTERNET)
|
||||||
|
} catch (e: SerializationException) {
|
||||||
|
logNetworkError(e, "Serialization failure")
|
||||||
|
Result.Error(DataError.Network.SERIALIZATION)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
// Ktor's ContentNegotiation wraps a kotlinx SerializationException (malformed/garbage body)
|
||||||
|
// in its own ContentConvertException, so the catch above misses it. Scan the cause chain so a
|
||||||
|
// bad payload still maps to SERIALIZATION instead of the generic UNKNOWN.
|
||||||
|
if (generateSequence(e as Throwable) { it.cause }.any { it is SerializationException }) {
|
||||||
|
logNetworkError(e, "Serialization failure (wrapped)")
|
||||||
|
Result.Error(DataError.Network.SERIALIZATION)
|
||||||
|
} else {
|
||||||
|
logNetworkError(e, "Unknown network failure")
|
||||||
|
Result.Error(DataError.Network.UNKNOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a caught network error. `@PublishedApi internal` so the public inline [safeCall] can call it
|
||||||
|
* across modules WITHOUT leaking Timber: the Timber dependency stays inside `:core:data` because
|
||||||
|
* this function's body is not inlined into the caller.
|
||||||
|
*/
|
||||||
|
@PublishedApi
|
||||||
|
internal fun logNetworkError(throwable: Throwable, message: String) {
|
||||||
|
Timber.tag("HttpClient").e(throwable, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps HTTP status codes to typed [DataError.Network] (covering 400/403/404 as well). */
|
||||||
|
suspend inline fun <reified T> responseToResult(
|
||||||
|
response: HttpResponse,
|
||||||
|
): Result<T, DataError.Network> {
|
||||||
|
return when (response.status.value) {
|
||||||
|
in 200..299 -> Result.Success(response.body<T>())
|
||||||
|
400 -> Result.Error(DataError.Network.BAD_REQUEST)
|
||||||
|
401 -> Result.Error(DataError.Network.UNAUTHORIZED)
|
||||||
|
403 -> Result.Error(DataError.Network.FORBIDDEN)
|
||||||
|
404 -> Result.Error(DataError.Network.NOT_FOUND)
|
||||||
|
408 -> Result.Error(DataError.Network.REQUEST_TIMEOUT)
|
||||||
|
409 -> Result.Error(DataError.Network.CONFLICT)
|
||||||
|
413 -> Result.Error(DataError.Network.PAYLOAD_TOO_LARGE)
|
||||||
|
429 -> Result.Error(DataError.Network.TOO_MANY_REQUESTS)
|
||||||
|
503 -> Result.Error(DataError.Network.SERVICE_UNAVAILABLE)
|
||||||
|
in 500..599 -> Result.Error(DataError.Network.SERVER_ERROR)
|
||||||
|
else -> Result.Error(DataError.Network.UNKNOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prepends [BuildConfig.BASE_URL] unless [route] is already absolute. */
|
||||||
|
fun constructRoute(route: String): String {
|
||||||
|
return when {
|
||||||
|
route.contains(BuildConfig.BASE_URL) -> route
|
||||||
|
route.startsWith("/") -> BuildConfig.BASE_URL + route
|
||||||
|
else -> BuildConfig.BASE_URL + "/$route"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.example.architecture.core.data.network
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.HttpClientEngine
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.plugins.defaultRequest
|
||||||
|
import io.ktor.client.plugins.logging.LogLevel
|
||||||
|
import io.ktor.client.plugins.logging.Logging
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import timber.log.Timber
|
||||||
|
import io.ktor.client.plugins.logging.Logger as KtorLogger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the app's single [HttpClient]. The [engine] is injected so tests can pass a Ktor
|
||||||
|
* `MockEngine` while production passes OkHttp (see `coreDataModule`). Ktor logging is bridged to
|
||||||
|
* Timber so all logs flow through one tree (planted in the Application).
|
||||||
|
*/
|
||||||
|
object HttpClientFactory {
|
||||||
|
fun create(engine: HttpClientEngine): HttpClient {
|
||||||
|
return HttpClient(engine) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
install(Logging) {
|
||||||
|
logger = object : KtorLogger {
|
||||||
|
override fun log(message: String) {
|
||||||
|
Timber.tag("HttpClient").d(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
level = LogLevel.ALL
|
||||||
|
}
|
||||||
|
defaultRequest {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
core/design-system/build.gradle.kts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.architecture.android.library)
|
||||||
|
alias(libs.plugins.architecture.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.architecture.core.design.system"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Coil is internal to NetworkImage; no Coil types leak into public signatures.
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.coil.network.okhttp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.example.architecture.core.design.system.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slot-API card. Callers compose into an optional [header] slot and the [content] slot
|
||||||
|
* (a `ColumnScope`), and may make the whole card clickable. Feature code decides what goes inside.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppCard(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
header: (@Composable () -> Unit)? = null,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val body: @Composable ColumnScope.() -> Unit = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
header?.invoke()
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onClick != null) {
|
||||||
|
Card(onClick = onClick, modifier = modifier, content = body)
|
||||||
|
} else {
|
||||||
|
Card(modifier = modifier, content = body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun AppCardPreview() {
|
||||||
|
AppTheme {
|
||||||
|
AppCard(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
onClick = {},
|
||||||
|
header = { Text("Rick Sanchez", style = MaterialTheme.typography.titleMedium) },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Human · Alive · Earth (C-137)",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.architecture.core.design.system.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper over [Scaffold] giving screens a consistent surface. Slot API: callers provide the
|
||||||
|
* [topBar] and the [content] (which receives the inner [PaddingValues] to consume).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppScaffold(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
topBar: @Composable () -> Unit = {},
|
||||||
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = topBar,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.example.architecture.core.design.system.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.architecture.core.design.system.R
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centered error message with an optional retry button. The message is already-resolved text
|
||||||
|
* (the caller maps its error/`UiText` to a String); the retry label is localized here.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ErrorState(
|
||||||
|
message: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onRetry: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
if (onRetry != null) {
|
||||||
|
Button(onClick = onRetry) {
|
||||||
|
Text(text = stringResource(R.string.designsystem_retry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun ErrorStatePreview() {
|
||||||
|
AppTheme {
|
||||||
|
ErrorState(message = "No internet connection.", onRetry = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.architecture.core.design.system.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoadingIndicator(modifier: Modifier = Modifier) {
|
||||||
|
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun LoadingIndicatorPreview() {
|
||||||
|
AppTheme { LoadingIndicator() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.example.architecture.core.design.system.component
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.crossfade
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coil-backed remote image. Coil 3 auto-registers the OkHttp network fetcher from
|
||||||
|
* `coil-network-okhttp` on the classpath, so callers just pass a URL.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NetworkImage(
|
||||||
|
imageUrl: String?,
|
||||||
|
contentDescription: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentScale: ContentScale = ContentScale.Crop,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(imageUrl)
|
||||||
|
.crossfade(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
contentScale = contentScale,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.example.architecture.core.design.system.modifier
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animated placeholder shimmer for loading skeletons. Implemented as a `Modifier` extension (not a
|
||||||
|
* `@Composable`); `composed` lets it read the theme and animate while the gradient is repainted
|
||||||
|
* below the recomposition layer via [background].
|
||||||
|
*/
|
||||||
|
fun Modifier.shimmerEffect(): Modifier = composed {
|
||||||
|
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||||
|
val startOffsetX by transition.animateFloat(
|
||||||
|
initialValue = -2f * size.width,
|
||||||
|
targetValue = 2f * size.width,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1200),
|
||||||
|
repeatMode = RepeatMode.Restart,
|
||||||
|
),
|
||||||
|
label = "shimmerOffsetX",
|
||||||
|
)
|
||||||
|
|
||||||
|
val base = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
val highlight = MaterialTheme.colorScheme.surface
|
||||||
|
|
||||||
|
background(
|
||||||
|
brush = Brush.linearGradient(
|
||||||
|
colors = listOf(base, highlight, base),
|
||||||
|
start = Offset(startOffsetX, 0f),
|
||||||
|
end = Offset(startOffsetX + size.width, size.height.toFloat()),
|
||||||
|
),
|
||||||
|
).onGloballyPositioned { size = it.size }
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.example.architecture.core.design.system.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Brand palette - seeded from the Android green used by the project.
|
||||||
|
private val Green10 = Color(0xFF00210B)
|
||||||
|
private val Green20 = Color(0xFF003918)
|
||||||
|
private val Green40 = Color(0xFF1E6C36)
|
||||||
|
private val Green80 = Color(0xFF8FD89B)
|
||||||
|
private val Green90 = Color(0xFFAAF5B5)
|
||||||
|
|
||||||
|
private val Teal40 = Color(0xFF36687A)
|
||||||
|
private val Teal80 = Color(0xFF9ECEE3)
|
||||||
|
|
||||||
|
private val Neutral10 = Color(0xFF191C1A)
|
||||||
|
private val Neutral90 = Color(0xFFE1E3DE)
|
||||||
|
private val Neutral99 = Color(0xFFFBFDF7)
|
||||||
|
|
||||||
|
internal val LightColorScheme = lightColorScheme(
|
||||||
|
primary = Green40,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = Green90,
|
||||||
|
onPrimaryContainer = Green10,
|
||||||
|
secondary = Teal40,
|
||||||
|
onSecondary = Color.White,
|
||||||
|
background = Neutral99,
|
||||||
|
onBackground = Neutral10,
|
||||||
|
surface = Neutral99,
|
||||||
|
onSurface = Neutral10,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = Green80,
|
||||||
|
onPrimary = Green20,
|
||||||
|
primaryContainer = Green40,
|
||||||
|
onPrimaryContainer = Green90,
|
||||||
|
secondary = Teal80,
|
||||||
|
onSecondary = Neutral10,
|
||||||
|
background = Neutral10,
|
||||||
|
onBackground = Neutral90,
|
||||||
|
surface = Neutral10,
|
||||||
|
onSurface = Neutral90,
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.architecture.core.design.system.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
internal val AppShapes = Shapes(
|
||||||
|
small = RoundedCornerShape(8.dp),
|
||||||
|
medium = RoundedCornerShape(12.dp),
|
||||||
|
large = RoundedCornerShape(16.dp),
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.example.architecture.core.design.system.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single Compose theme for the app. Every screen and every `@Preview` is wrapped in this so
|
||||||
|
* they reflect real appearance. Dynamic color is intentionally off to keep the brand identity.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
|
||||||
|
typography = AppTypography,
|
||||||
|
shapes = AppShapes,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.example.architecture.core.design.system.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
|
||||||
|
// Material3 baseline type scale. Swap in custom font families here if the brand needs them.
|
||||||
|
internal val AppTypography = Typography()
|
||||||
3
core/design-system/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="designsystem_retry">Retry</string>
|
||||||
|
</resources>
|
||||||
3
core/domain/build.gradle.kts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.architecture.domain.module)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.example.architecture.core.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Errors raised by the data layer. [Network] for remote calls, [Local] for on-device storage.
|
||||||
|
* A repository that merges multiple sources can expose the [DataError] supertype.
|
||||||
|
*/
|
||||||
|
sealed interface DataError : Error {
|
||||||
|
enum class Network : DataError {
|
||||||
|
BAD_REQUEST,
|
||||||
|
REQUEST_TIMEOUT,
|
||||||
|
UNAUTHORIZED,
|
||||||
|
FORBIDDEN,
|
||||||
|
NOT_FOUND,
|
||||||
|
CONFLICT,
|
||||||
|
TOO_MANY_REQUESTS,
|
||||||
|
NO_INTERNET,
|
||||||
|
PAYLOAD_TOO_LARGE,
|
||||||
|
SERVER_ERROR,
|
||||||
|
SERVICE_UNAVAILABLE,
|
||||||
|
SERIALIZATION,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Local : DataError {
|
||||||
|
DISK_FULL,
|
||||||
|
NOT_FOUND,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.architecture.core.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker for every typed error in the app. Each layer/feature defines its own [Error]
|
||||||
|
* implementations (e.g. [DataError], or a feature validation enum) and pairs them with [Result].
|
||||||
|
*/
|
||||||
|
interface Error
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.example.architecture.core.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed result usable across every layer (data, domain, presentation, validation). Carries either
|
||||||
|
* success [data] or a typed [Error]. Prefer this over throwing for expected failures.
|
||||||
|
*/
|
||||||
|
sealed interface Result<out D, out E : Error> {
|
||||||
|
data class Success<out D>(val data: D) : Result<D, Nothing>
|
||||||
|
|
||||||
|
// The bound is fully qualified because inside this scope `Error` would resolve to this class.
|
||||||
|
data class Error<out E : com.example.architecture.core.domain.Error>(
|
||||||
|
val error: E,
|
||||||
|
) : Result<Nothing, E>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A [Result] that carries no success payload - for operations that either succeed or fail. */
|
||||||
|
typealias EmptyResult<E> = Result<Unit, E>
|
||||||
|
|
||||||
|
inline fun <T, E : Error, R> Result<T, E>.map(map: (T) -> R): Result<R, E> {
|
||||||
|
return when (this) {
|
||||||
|
is Result.Error -> Result.Error(error)
|
||||||
|
is Result.Success -> Result.Success(map(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T, E : Error> Result<T, E>.onSuccess(action: (T) -> Unit): Result<T, E> {
|
||||||
|
return when (this) {
|
||||||
|
is Result.Error -> this
|
||||||
|
is Result.Success -> {
|
||||||
|
action(data)
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T, E : Error> Result<T, E>.onFailure(action: (E) -> Unit): Result<T, E> {
|
||||||
|
return when (this) {
|
||||||
|
is Result.Error -> {
|
||||||
|
action(error)
|
||||||
|
this
|
||||||
|
}
|
||||||
|
is Result.Success -> this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T, E : Error> Result<T, E>.asEmptyResult(): EmptyResult<E> = map { }
|
||||||
16
core/presentation/build.gradle.kts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.architecture.android.library)
|
||||||
|
alias(libs.plugins.architecture.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.architecture.core.presentation"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:domain"))
|
||||||
|
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.example.architecture.core.presentation
|
||||||
|
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a [DataError] to user-facing [UiText]. Every displayed case has its own message; anything
|
||||||
|
* else (including the explicit `UNKNOWN` cases) falls back to a generic message.
|
||||||
|
*/
|
||||||
|
fun DataError.toUiText(): UiText {
|
||||||
|
val resId = when (this) {
|
||||||
|
DataError.Network.NO_INTERNET -> R.string.error_no_internet
|
||||||
|
DataError.Network.REQUEST_TIMEOUT -> R.string.error_request_timeout
|
||||||
|
DataError.Network.UNAUTHORIZED -> R.string.error_unauthorized
|
||||||
|
DataError.Network.FORBIDDEN -> R.string.error_forbidden
|
||||||
|
DataError.Network.NOT_FOUND -> R.string.error_not_found
|
||||||
|
DataError.Network.CONFLICT -> R.string.error_conflict
|
||||||
|
DataError.Network.TOO_MANY_REQUESTS -> R.string.error_too_many_requests
|
||||||
|
DataError.Network.PAYLOAD_TOO_LARGE -> R.string.error_payload_too_large
|
||||||
|
DataError.Network.SERVER_ERROR -> R.string.error_server
|
||||||
|
DataError.Network.SERVICE_UNAVAILABLE -> R.string.error_service_unavailable
|
||||||
|
DataError.Network.SERIALIZATION -> R.string.error_serialization
|
||||||
|
DataError.Network.BAD_REQUEST -> R.string.error_bad_request
|
||||||
|
DataError.Local.DISK_FULL -> R.string.error_disk_full
|
||||||
|
DataError.Local.NOT_FOUND -> R.string.error_not_found
|
||||||
|
else -> R.string.error_unknown
|
||||||
|
}
|
||||||
|
return UiText.StringResource(resId)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.example.architecture.core.presentation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects one-time [Flow] events (navigation, snackbars) lifecycle-awarely: only while the
|
||||||
|
* lifecycle is at least STARTED, and on `Main.immediate` so no event is missed during setup.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> ObserveAsEvents(
|
||||||
|
flow: Flow<T>,
|
||||||
|
key1: Any? = null,
|
||||||
|
key2: Any? = null,
|
||||||
|
onEvent: (T) -> Unit,
|
||||||
|
) {
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
LaunchedEffect(flow, lifecycleOwner.lifecycle, key1, key2) {
|
||||||
|
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
flow.collect(onEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.example.architecture.core.presentation
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A string the UI will show that either is already concrete ([DynamicString]) or comes from a
|
||||||
|
* string resource ([StringResource], so it can be localized). The type itself is Compose-free, so a
|
||||||
|
* UI-agnostic ViewModel can hold `UiText?` in its state without depending on Compose; the actual
|
||||||
|
* resolution happens in the renderer via [asString].
|
||||||
|
*/
|
||||||
|
sealed interface UiText {
|
||||||
|
data class DynamicString(val value: String) : UiText
|
||||||
|
|
||||||
|
// Not a data class: Array has no structural equals. Compare by identity, like the framework does.
|
||||||
|
class StringResource(
|
||||||
|
@param:StringRes val id: Int,
|
||||||
|
val args: Array<Any> = emptyArray(),
|
||||||
|
) : UiText
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.example.architecture.core.presentation
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
||||||
|
/** Resolves to a [String] inside Compose (used by the Compose renderer). */
|
||||||
|
@Composable
|
||||||
|
fun UiText.asString(): String = when (this) {
|
||||||
|
is UiText.DynamicString -> value
|
||||||
|
is UiText.StringResource -> stringResource(id, *args)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves to a [String] with a plain [Context] (used by the Views/XML renderer). */
|
||||||
|
fun UiText.asString(context: Context): String = when (this) {
|
||||||
|
is UiText.DynamicString -> value
|
||||||
|
is UiText.StringResource -> context.getString(id, *args)
|
||||||
|
}
|
||||||
16
core/presentation/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="error_no_internet">No internet connection. Check your network and try again.</string>
|
||||||
|
<string name="error_request_timeout">The request timed out. Please try again.</string>
|
||||||
|
<string name="error_unauthorized">You are not authorized. Please sign in again.</string>
|
||||||
|
<string name="error_forbidden">You don\'t have permission to do that.</string>
|
||||||
|
<string name="error_not_found">We couldn\'t find what you were looking for.</string>
|
||||||
|
<string name="error_conflict">That action conflicts with the current state.</string>
|
||||||
|
<string name="error_too_many_requests">Too many requests. Please slow down and try again.</string>
|
||||||
|
<string name="error_payload_too_large">The request was too large.</string>
|
||||||
|
<string name="error_server">Something went wrong on our end. Please try again later.</string>
|
||||||
|
<string name="error_service_unavailable">The service is temporarily unavailable. Please try again later.</string>
|
||||||
|
<string name="error_serialization">We received an unexpected response. Please try again later.</string>
|
||||||
|
<string name="error_bad_request">The request was invalid.</string>
|
||||||
|
<string name="error_disk_full">Your device is out of storage space.</string>
|
||||||
|
<string name="error_unknown">Something went wrong. Please try again.</string>
|
||||||
|
</resources>
|
||||||
16
feature/about/presentation/build.gradle.kts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.architecture.android.feature)
|
||||||
|
// For @Serializable type-safe navigation routes.
|
||||||
|
alias(libs.plugins.architecture.kotlinx.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MVVM contrast screen (StateFlow + plain VM methods, no Action/Event funnel). Static content,
|
||||||
|
// so it has no data/domain modules.
|
||||||
|
android {
|
||||||
|
namespace = "com.example.architecture.feature.about.presentation"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:presentation"))
|
||||||
|
implementation(project(":core:design-system"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.architecture.feature.about.presentation
|
||||||
|
|
||||||
|
import androidx.navigation.NavGraphBuilder
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Type-safe route for the About screen. */
|
||||||
|
@Serializable
|
||||||
|
data object AboutRoute
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
fun NavGraphBuilder.aboutGraph(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
composable<AboutRoute> {
|
||||||
|
AboutRoot(onNavigateBack = onNavigateBack)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.example.architecture.feature.about.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.example.architecture.core.design.system.component.AppCard
|
||||||
|
import com.example.architecture.core.design.system.component.AppScaffold
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
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
|
||||||
|
* [AboutState] and passes the ViewModel's **method reference** straight through - there is no
|
||||||
|
* `onAction` funnel and no event observation, because this screen has neither.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AboutRoot(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: AboutViewModel = org.koin.androidx.compose.koinViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
AboutScreen(
|
||||||
|
state = state,
|
||||||
|
onToggleMvvmNote = viewModel::onToggleMvvmNote,
|
||||||
|
onNavigateBack = onNavigateBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AboutScreen(
|
||||||
|
state: AboutState,
|
||||||
|
onToggleMvvmNote: () -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
AppScaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.about_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.cd_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(text = state.appName, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Text(text = state.description, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_architecture_header),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
state.architectureHighlights.forEach { highlight ->
|
||||||
|
Text(text = "• $highlight", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The expandable card is driven entirely by the VM's plain method - the MVVM contrast.
|
||||||
|
AppCard(
|
||||||
|
onClick = onToggleMvvmNote,
|
||||||
|
header = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_mvvm_header),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (state.showMvvmNote) state.mvvmNote else stringResource(R.string.about_mvvm_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about_links_header),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
state.links.forEach { link ->
|
||||||
|
TextButton(onClick = { uriHandler.openUri(link.url) }) {
|
||||||
|
Text(text = link.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun AboutScreenPreview() {
|
||||||
|
AppTheme {
|
||||||
|
AboutScreen(
|
||||||
|
state = AboutState(
|
||||||
|
appName = "Android Architecture Showcase",
|
||||||
|
description = "A reference Android app demonstrating a modern multi-module architecture.",
|
||||||
|
architectureHighlights = listOf(
|
||||||
|
"Multi-module Clean Architecture.",
|
||||||
|
"MVI primary, MVVM contrast.",
|
||||||
|
),
|
||||||
|
mvvmNote = "MVI funnels intents through onAction; this screen uses plain VM methods.",
|
||||||
|
showMvvmNote = true,
|
||||||
|
links = listOf(AboutLink("GitHub repository", "https://example.com")),
|
||||||
|
),
|
||||||
|
onToggleMvvmNote = {},
|
||||||
|
onNavigateBack = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.example.architecture.feature.about.presentation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import com.example.architecture.feature.about.presentation.model.AboutLink
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the MVVM About screen.
|
||||||
|
*
|
||||||
|
* Contrast with the MVI [com.example.architecture.feature.characters.presentation.CharacterListState]:
|
||||||
|
* that one is UI-agnostic and stays Compose-free by using `ImmutableList`. This module is a
|
||||||
|
* Compose-only presentation layer, so it simply annotates the state `@Stable` (cheaper than pulling
|
||||||
|
* in kotlinx-collections-immutable) to keep the `List` fields from defeating recomposition skipping.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
data class AboutState(
|
||||||
|
val appName: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
val architectureHighlights: List<String> = emptyList(),
|
||||||
|
val mvvmNote: String = "",
|
||||||
|
val showMvvmNote: Boolean = false,
|
||||||
|
val links: List<AboutLink> = emptyList(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.example.architecture.feature.about.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.example.architecture.feature.about.presentation.model.AboutLink
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **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
|
||||||
|
* invokes the ViewModel's **plain public methods** directly. That is the whole point of this screen:
|
||||||
|
* for small, mostly-static UI, the MVI ceremony (single `onAction` funnel + one-time event channel)
|
||||||
|
* isn't worth it. See [AboutState] for the matching stability note, and the in-app "Why is this
|
||||||
|
* screen MVVM?" card / the README for when to pick each pattern.
|
||||||
|
*
|
||||||
|
* The showcase copy lives here as state (rather than in string resources) precisely to demonstrate
|
||||||
|
* the "StateFlow holds the content" MVVM shape; real localizable product copy would use resources.
|
||||||
|
*/
|
||||||
|
class AboutViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(
|
||||||
|
AboutState(
|
||||||
|
appName = "Android Architecture Showcase",
|
||||||
|
description = "A reference Android app that demonstrates a modern, multi-module " +
|
||||||
|
"architecture: feature-layered Clean Architecture, a typed networking + error stack, " +
|
||||||
|
"and a single presentation layer rendered by two different UI toolkits.",
|
||||||
|
architectureHighlights = listOf(
|
||||||
|
"Multi-module, feature-layered Clean Architecture (presentation → domain ← data).",
|
||||||
|
"Gradle convention plugins with a single version catalog as the source of truth.",
|
||||||
|
"MVI is the primary pattern; this About screen is the MVVM contrast.",
|
||||||
|
"One UI-agnostic ViewModel rendered by both Jetpack Compose and classic Android Views.",
|
||||||
|
"Koin for DI, Ktor for networking, type-safe Compose Navigation, Coil for images.",
|
||||||
|
"Typed Result / DataError handling surfaced to the UI as UiText.",
|
||||||
|
),
|
||||||
|
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 " +
|
||||||
|
"structure pays off when state is complex and interacting - like the paginated, " +
|
||||||
|
"process-death-restorable characters list. This screen is intentionally MVVM instead: " +
|
||||||
|
"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 " +
|
||||||
|
"and side effects matter; reach for MVVM when the screen is small and mostly static.",
|
||||||
|
links = listOf(
|
||||||
|
AboutLink(
|
||||||
|
label = "GitHub repository",
|
||||||
|
url = "https://github.com/AdrianKuta/android-architecture-showcase",
|
||||||
|
),
|
||||||
|
AboutLink(
|
||||||
|
label = "Rick & Morty API (data source)",
|
||||||
|
url = "https://rickandmortyapi.com",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val state: StateFlow<AboutState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
/** MVVM: a plain public method mutates state directly - no Action object, no reducer funnel. */
|
||||||
|
fun onToggleMvvmNote() {
|
||||||
|
_state.update { it.copy(showMvvmNote = !it.showMvvmNote) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.architecture.feature.about.presentation.di
|
||||||
|
|
||||||
|
import com.example.architecture.feature.about.presentation.AboutViewModel
|
||||||
|
import org.koin.core.module.dsl.viewModelOf
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
/** Presentation DI for the About feature. */
|
||||||
|
val aboutPresentationModule = module {
|
||||||
|
viewModelOf(::AboutViewModel)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.architecture.feature.about.presentation.model
|
||||||
|
|
||||||
|
/** A labelled external link shown on the About screen. */
|
||||||
|
data class AboutLink(
|
||||||
|
val label: String,
|
||||||
|
val url: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="about_title">About</string>
|
||||||
|
<string name="about_architecture_header">Architecture highlights</string>
|
||||||
|
<string name="about_mvvm_header">Why is this screen MVVM?</string>
|
||||||
|
<string name="about_mvvm_hint">Tap to see how this MVVM screen differs from the app\'s MVI screens.</string>
|
||||||
|
<string name="about_links_header">Links</string>
|
||||||
|
<string name="cd_back">Back</string>
|
||||||
|
</resources>
|
||||||
21
feature/characters/data/build.gradle.kts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.architecture.android.library)
|
||||||
|
alias(libs.plugins.architecture.koin)
|
||||||
|
alias(libs.plugins.architecture.kotlinx.serialization)
|
||||||
|
alias(libs.plugins.architecture.android.unit.test)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.architecture.feature.characters.data"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:domain"))
|
||||||
|
implementation(project(":core:data"))
|
||||||
|
implementation(project(":feature:characters:domain"))
|
||||||
|
|
||||||
|
// Swap a Ktor MockEngine into HttpClientFactory.create(...) for the repository test.
|
||||||
|
testImplementation(libs.ktor.client.mock)
|
||||||
|
testImplementation(libs.ktor.client.content.negotiation)
|
||||||
|
testImplementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.example.architecture.feature.characters.data
|
||||||
|
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.core.domain.map
|
||||||
|
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
|
||||||
|
import com.example.architecture.feature.characters.data.mappers.toCharacterDetails
|
||||||
|
import com.example.architecture.feature.characters.data.mappers.toDomain
|
||||||
|
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network-backed [CharacterRepository]. Maps DTOs to domain via the mappers; the `Result`'s
|
||||||
|
* `DataError.Network` widens to the `DataError` supertype through `Result`'s covariance.
|
||||||
|
*/
|
||||||
|
internal class NetworkCharacterRepository(
|
||||||
|
private val dataSource: KtorCharacterDataSource,
|
||||||
|
) : CharacterRepository {
|
||||||
|
override suspend fun getCharacters(page: Int): Result<CharactersPage, DataError> =
|
||||||
|
dataSource.getCharacters(page).map { it.toDomain() }
|
||||||
|
|
||||||
|
override suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError> =
|
||||||
|
dataSource.getCharacter(id).map { it.toCharacterDetails() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.datasource
|
||||||
|
|
||||||
|
import com.example.architecture.core.data.network.get
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.feature.characters.data.dto.CharacterDto
|
||||||
|
import com.example.architecture.feature.characters.data.dto.CharactersResponseDto
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
internal class KtorCharacterDataSource(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
|
) {
|
||||||
|
suspend fun getCharacters(page: Int): Result<CharactersResponseDto, DataError.Network> =
|
||||||
|
httpClient.get(route = "/character", queryParameters = mapOf("page" to page))
|
||||||
|
|
||||||
|
suspend fun getCharacter(id: Int): Result<CharacterDto, DataError.Network> =
|
||||||
|
httpClient.get(route = "/character/$id")
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.di
|
||||||
|
|
||||||
|
import com.example.architecture.feature.characters.data.NetworkCharacterRepository
|
||||||
|
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
|
||||||
|
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||||
|
import org.koin.core.module.dsl.bind
|
||||||
|
import org.koin.core.module.dsl.singleOf
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val charactersDataModule = module {
|
||||||
|
singleOf(::KtorCharacterDataSource)
|
||||||
|
singleOf(::NetworkCharacterRepository) { bind<CharacterRepository>() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CharacterDto(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val status: String,
|
||||||
|
val species: String,
|
||||||
|
val type: String,
|
||||||
|
val gender: String,
|
||||||
|
val origin: LocationRefDto,
|
||||||
|
val location: LocationRefDto,
|
||||||
|
val image: String,
|
||||||
|
val episode: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LocationRefDto(
|
||||||
|
val name: String,
|
||||||
|
val url: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CharactersResponseDto(
|
||||||
|
val info: PageInfoDto,
|
||||||
|
val results: List<CharacterDto>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PageInfoDto(
|
||||||
|
val count: Int,
|
||||||
|
val pages: Int,
|
||||||
|
val next: String?,
|
||||||
|
val prev: String?,
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.example.architecture.feature.characters.data.mappers
|
||||||
|
|
||||||
|
import com.example.architecture.feature.characters.data.dto.CharacterDto
|
||||||
|
import com.example.architecture.feature.characters.data.dto.CharactersResponseDto
|
||||||
|
import com.example.architecture.feature.characters.domain.model.Character
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||||
|
|
||||||
|
internal fun CharactersResponseDto.toDomain(): CharactersPage = CharactersPage(
|
||||||
|
characters = results.map { it.toCharacter() },
|
||||||
|
nextPage = info.next?.toPageNumber(),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun CharacterDto.toCharacter(): Character = Character(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
status = status.toCharacterStatus(),
|
||||||
|
species = species,
|
||||||
|
imageUrl = image,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun CharacterDto.toCharacterDetails(): CharacterDetails = CharacterDetails(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
status = status.toCharacterStatus(),
|
||||||
|
species = species,
|
||||||
|
type = type,
|
||||||
|
gender = gender,
|
||||||
|
origin = origin.name,
|
||||||
|
location = location.name,
|
||||||
|
imageUrl = image,
|
||||||
|
episodeCount = episode.size,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun String.toCharacterStatus(): CharacterStatus = when (lowercase()) {
|
||||||
|
"alive" -> CharacterStatus.ALIVE
|
||||||
|
"dead" -> CharacterStatus.DEAD
|
||||||
|
else -> CharacterStatus.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The API's `next` is a full URL like `.../character?page=2`; pull the page number out of it. */
|
||||||
|
private fun String.toPageNumber(): Int? =
|
||||||
|
Regex("[?&]page=(\\d+)").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package com.example.architecture.feature.characters.data
|
||||||
|
|
||||||
|
import assertk.assertThat
|
||||||
|
import assertk.assertions.endsWith
|
||||||
|
import assertk.assertions.isEqualTo
|
||||||
|
import assertk.assertions.isInstanceOf
|
||||||
|
import assertk.assertions.isNotNull
|
||||||
|
import com.example.architecture.core.data.network.HttpClientFactory
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.feature.characters.data.datasource.KtorCharacterDataSource
|
||||||
|
import io.ktor.client.engine.mock.MockEngine
|
||||||
|
import io.ktor.client.engine.mock.MockRequestHandleScope
|
||||||
|
import io.ktor.client.engine.mock.respond
|
||||||
|
import io.ktor.client.request.HttpRequestData
|
||||||
|
import io.ktor.client.request.HttpResponseData
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.headersOf
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* 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
|
||||||
|
* malformed-body → SERIALIZATION case.
|
||||||
|
*/
|
||||||
|
class NetworkCharacterRepositoryTest {
|
||||||
|
|
||||||
|
private fun repository(
|
||||||
|
handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData,
|
||||||
|
): NetworkCharacterRepository {
|
||||||
|
val engine = MockEngine { request -> handler(request) }
|
||||||
|
val httpClient = HttpClientFactory.create(engine)
|
||||||
|
return NetworkCharacterRepository(KtorCharacterDataSource(httpClient))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonHeaders() = headersOf(HttpHeaders.ContentType, "application/json")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getCharacters maps a successful response to a domain page`() = runTest {
|
||||||
|
var requestedPath: String? = null
|
||||||
|
var requestedPage: String? = null
|
||||||
|
val repository = repository { request ->
|
||||||
|
requestedPath = request.url.encodedPath
|
||||||
|
requestedPage = request.url.parameters["page"]
|
||||||
|
respond(content = CHARACTERS_PAGE_JSON, status = HttpStatusCode.OK, headers = jsonHeaders())
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = repository.getCharacters(page = 3)
|
||||||
|
|
||||||
|
// Request construction: correct endpoint and the page forwarded as a query param.
|
||||||
|
assertThat(requestedPath).isNotNull().endsWith("/character")
|
||||||
|
assertThat(requestedPage).isEqualTo("3")
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(Result.Success::class)
|
||||||
|
val page = (result as Result.Success).data
|
||||||
|
assertThat(page.characters.size).isEqualTo(2)
|
||||||
|
assertThat(page.characters.first().name).isEqualTo("Rick Sanchez")
|
||||||
|
// `next` URL ".../character?page=2" is parsed to a page number.
|
||||||
|
assertThat(page.nextPage).isEqualTo(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getCharacters maps 404 to NOT_FOUND`() = runTest {
|
||||||
|
val repository = repository {
|
||||||
|
respond(content = "", status = HttpStatusCode.NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = repository.getCharacters(page = 1)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(Result.Error(DataError.Network.NOT_FOUND))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getCharacters maps 500 to SERVER_ERROR`() = runTest {
|
||||||
|
val repository = repository {
|
||||||
|
respond(content = "", status = HttpStatusCode.InternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = repository.getCharacters(page = 1)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(Result.Error(DataError.Network.SERVER_ERROR))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getCharacters maps a malformed body to SERIALIZATION`() = runTest {
|
||||||
|
val repository = repository {
|
||||||
|
respond(content = "{ this is not valid json", status = HttpStatusCode.OK, headers = jsonHeaders())
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = repository.getCharacters(page = 1)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(Result.Error(DataError.Network.SERIALIZATION))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getCharacterDetails maps a successful response to domain details`() = runTest {
|
||||||
|
var requestedPath: String? = null
|
||||||
|
val repository = repository { request ->
|
||||||
|
requestedPath = request.url.encodedPath
|
||||||
|
respond(content = CHARACTER_JSON, status = HttpStatusCode.OK, headers = jsonHeaders())
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = repository.getCharacterDetails(id = 1)
|
||||||
|
|
||||||
|
// Request construction: the id is placed in the path.
|
||||||
|
assertThat(requestedPath).isNotNull().endsWith("/character/1")
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(Result.Success::class)
|
||||||
|
val details = (result as Result.Success).data
|
||||||
|
assertThat(details.name).isEqualTo("Rick Sanchez")
|
||||||
|
assertThat(details.origin).isEqualTo("Earth (C-137)")
|
||||||
|
assertThat(details.episodeCount).isEqualTo(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val CHARACTER_JSON = """
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Rick Sanchez",
|
||||||
|
"status": "Alive",
|
||||||
|
"species": "Human",
|
||||||
|
"type": "",
|
||||||
|
"gender": "Male",
|
||||||
|
"origin": { "name": "Earth (C-137)", "url": "" },
|
||||||
|
"location": { "name": "Citadel of Ricks", "url": "" },
|
||||||
|
"image": "https://example.com/1.png",
|
||||||
|
"episode": ["e1", "e2", "e3"]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val CHARACTERS_PAGE_JSON = """
|
||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"count": 2,
|
||||||
|
"pages": 1,
|
||||||
|
"next": "https://rickandmortyapi.com/api/character?page=2",
|
||||||
|
"prev": null
|
||||||
|
},
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 1, "name": "Rick Sanchez", "status": "Alive", "species": "Human",
|
||||||
|
"type": "", "gender": "Male",
|
||||||
|
"origin": { "name": "Earth (C-137)", "url": "" },
|
||||||
|
"location": { "name": "Citadel of Ricks", "url": "" },
|
||||||
|
"image": "https://example.com/1.png", "episode": ["e1", "e2"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2, "name": "Morty Smith", "status": "Alive", "species": "Human",
|
||||||
|
"type": "", "gender": "Male",
|
||||||
|
"origin": { "name": "Earth (C-137)", "url": "" },
|
||||||
|
"location": { "name": "Citadel of Ricks", "url": "" },
|
||||||
|
"image": "https://example.com/2.png", "episode": ["e1"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
}
|
||||||
7
feature/characters/domain/build.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.architecture.domain.module)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:domain"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.architecture.feature.characters.domain
|
||||||
|
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract for the characters data layer. Lives in domain so presentation never depends on data.
|
||||||
|
* Returns the [DataError] supertype because an implementation may merge sources (e.g. an
|
||||||
|
* offline-first repository combining network + local).
|
||||||
|
*/
|
||||||
|
interface CharacterRepository {
|
||||||
|
suspend fun getCharacters(page: Int): Result<CharactersPage, DataError>
|
||||||
|
|
||||||
|
suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError>
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.architecture.feature.characters.domain.model
|
||||||
|
|
||||||
|
/** A character as shown in the list. */
|
||||||
|
data class Character(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val status: CharacterStatus,
|
||||||
|
val species: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.example.architecture.feature.characters.domain.model
|
||||||
|
|
||||||
|
/** Full character profile shown on the detail screen. */
|
||||||
|
data class CharacterDetails(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val status: CharacterStatus,
|
||||||
|
val species: String,
|
||||||
|
val type: String,
|
||||||
|
val gender: String,
|
||||||
|
val origin: String,
|
||||||
|
val location: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
val episodeCount: Int,
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.example.architecture.feature.characters.domain.model
|
||||||
|
|
||||||
|
/** Life status of a character. Mapped from the API's string in the data layer. */
|
||||||
|
enum class CharacterStatus {
|
||||||
|
ALIVE,
|
||||||
|
DEAD,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.example.architecture.feature.characters.domain.model
|
||||||
|
|
||||||
|
/** One page of characters plus the next page index ([nextPage] is null when there are no more). */
|
||||||
|
data class CharactersPage(
|
||||||
|
val characters: List<Character>,
|
||||||
|
val nextPage: Int?,
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.example.architecture.feature.characters.domain.usecase
|
||||||
|
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads one page of characters.
|
||||||
|
*
|
||||||
|
* **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
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* 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
|
||||||
|
* delete it and let the ViewModel call the repository.
|
||||||
|
*/
|
||||||
|
class GetCharactersPageUseCase(
|
||||||
|
private val characterRepository: CharacterRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(page: Int): Result<CharactersPage, DataError> =
|
||||||
|
characterRepository.getCharacters(page)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.example.architecture.feature.characters.domain.usecase
|
||||||
|
|
||||||
|
import assertk.assertThat
|
||||||
|
import assertk.assertions.isEqualTo
|
||||||
|
import assertk.assertions.isInstanceOf
|
||||||
|
import com.example.architecture.core.domain.DataError
|
||||||
|
import com.example.architecture.core.domain.Result
|
||||||
|
import com.example.architecture.feature.characters.domain.CharacterRepository
|
||||||
|
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.CharactersPage
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* JUnit 5 platform (see DomainModuleConventionPlugin); the [CharacterRepository] collaborator is a
|
||||||
|
* MockK mock, stubbed with `coEvery` and verified with `coVerify`.
|
||||||
|
*/
|
||||||
|
class GetCharactersPageUseCaseTest {
|
||||||
|
|
||||||
|
private val repository = mockk<CharacterRepository>()
|
||||||
|
private val useCase = GetCharactersPageUseCase(repository)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns the repository page on success`() = runTest {
|
||||||
|
val page = CharactersPage(characters = listOf(domainCharacter(1)), nextPage = 2)
|
||||||
|
coEvery { repository.getCharacters(1) } returns Result.Success(page)
|
||||||
|
|
||||||
|
val result = useCase(page = 1)
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(Result.Success(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `propagates the repository error`() = runTest {
|
||||||
|
coEvery { repository.getCharacters(1) } returns Result.Error(DataError.Network.SERVER_ERROR)
|
||||||
|
|
||||||
|
val result = useCase(page = 1)
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(Result.Error::class)
|
||||||
|
assertThat((result as Result.Error).error).isEqualTo(DataError.Network.SERVER_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `forwards the requested page number`() = runTest {
|
||||||
|
coEvery { repository.getCharacters(any()) } returns
|
||||||
|
Result.Success(CharactersPage(characters = emptyList(), nextPage = null))
|
||||||
|
|
||||||
|
useCase(page = 7)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { repository.getCharacters(7) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun domainCharacter(id: Int) = Character(
|
||||||
|
id = id,
|
||||||
|
name = "Character $id",
|
||||||
|
status = CharacterStatus.ALIVE,
|
||||||
|
species = "Human",
|
||||||
|
imageUrl = "https://example.com/$id.png",
|
||||||
|
)
|
||||||
|
}
|
||||||
21
feature/characters/presentation-compose/build.gradle.kts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.architecture.android.feature)
|
||||||
|
// For @Serializable type-safe navigation routes.
|
||||||
|
alias(libs.plugins.architecture.kotlinx.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.architecture.feature.characters.presentation.compose"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:presentation"))
|
||||||
|
implementation(project(":core:design-system"))
|
||||||
|
implementation(project(":feature:characters:domain"))
|
||||||
|
implementation(project(":feature:characters:presentation"))
|
||||||
|
|
||||||
|
// Instrumented Compose UI test (robot pattern). The Compose convention already adds the BOM to
|
||||||
|
// androidTestImplementation; ui-test-manifest provides the empty Activity ComposeTestRule hosts in.
|
||||||
|
androidTestImplementation(libs.bundles.compose.ui.test)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation.compose
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListAction
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListState
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
class CharacterListRobot(
|
||||||
|
private val composeRule: ComposeContentTestRule,
|
||||||
|
private val context: Context,
|
||||||
|
) {
|
||||||
|
private val recordedActions = mutableListOf<CharacterListAction>()
|
||||||
|
|
||||||
|
fun setContent(state: CharacterListState): CharacterListRobot {
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
CharacterListScreen(
|
||||||
|
state = state,
|
||||||
|
onAction = { recordedActions += it },
|
||||||
|
onOpenAbout = {},
|
||||||
|
onOpenViewsList = {},
|
||||||
|
onOpenErrorDemo = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertCharacterShown(name: String): CharacterListRobot {
|
||||||
|
composeRule.onNodeWithText(name).assertIsDisplayed()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertEmptyStateShown(): CharacterListRobot {
|
||||||
|
composeRule.onNodeWithText(context.getString(R.string.characters_empty)).assertIsDisplayed()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertErrorShown(message: String): CharacterListRobot {
|
||||||
|
composeRule.onNodeWithText(message).assertIsDisplayed()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertRetryShown(): CharacterListRobot {
|
||||||
|
composeRule.onNodeWithText(retryLabel).assertIsDisplayed()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clickCharacter(name: String): CharacterListRobot {
|
||||||
|
composeRule.onNodeWithText(name).performClick()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clickRetry(): CharacterListRobot {
|
||||||
|
composeRule.onNodeWithText(retryLabel).performClick()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assertActionRecorded(action: CharacterListAction): CharacterListRobot {
|
||||||
|
assertTrue(
|
||||||
|
"Expected $action to be recorded, but got $recordedActions",
|
||||||
|
recordedActions.contains(action),
|
||||||
|
)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// The retry label lives in the design-system module; reference its R directly (non-transitive R).
|
||||||
|
private val retryLabel: String
|
||||||
|
get() = context.getString(com.example.architecture.core.design.system.R.string.designsystem_retry)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation.compose
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.example.architecture.core.presentation.UiText
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListAction
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListState
|
||||||
|
import com.example.architecture.feature.characters.presentation.model.CharacterUi
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented Compose UI test for [CharacterListScreen] using [CharacterListRobot]. Runs on a
|
||||||
|
* device/emulator (`connectedDebugAndroidTest`); CI assembles it. Asserts rendered items, the
|
||||||
|
* empty + error states, and that user gestures fire the right MVI [CharacterListAction]s.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class CharacterListScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
private fun robot() = CharacterListRobot(composeRule, context)
|
||||||
|
|
||||||
|
private val loadedState = CharacterListState(
|
||||||
|
characters = persistentListOf(
|
||||||
|
CharacterUi(1, "Rick Sanchez", "Human", "", CharacterStatus.ALIVE),
|
||||||
|
CharacterUi(2, "Morty Smith", "Human", "", CharacterStatus.ALIVE),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rendersCharacterItems() {
|
||||||
|
robot()
|
||||||
|
.setContent(loadedState)
|
||||||
|
.assertCharacterShown("Rick Sanchez")
|
||||||
|
.assertCharacterShown("Morty Smith")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsEmptyState() {
|
||||||
|
robot()
|
||||||
|
.setContent(CharacterListState())
|
||||||
|
.assertEmptyStateShown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsErrorStateWithRetry() {
|
||||||
|
robot()
|
||||||
|
.setContent(CharacterListState(error = UiText.DynamicString("Boom")))
|
||||||
|
.assertErrorShown("Boom")
|
||||||
|
.assertRetryShown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tappingAnItemFiresOnCharacterClick() {
|
||||||
|
robot()
|
||||||
|
.setContent(loadedState)
|
||||||
|
.clickCharacter("Rick Sanchez")
|
||||||
|
.assertActionRecorded(CharacterListAction.OnCharacterClick(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tappingRetryFiresOnRetry() {
|
||||||
|
robot()
|
||||||
|
.setContent(CharacterListState(error = UiText.DynamicString("Boom")))
|
||||||
|
.clickRetry()
|
||||||
|
.assertActionRecorded(CharacterListAction.OnRetry)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.example.architecture.core.design.system.component.AppScaffold
|
||||||
|
import com.example.architecture.core.design.system.component.ErrorState
|
||||||
|
import com.example.architecture.core.design.system.component.LoadingIndicator
|
||||||
|
import com.example.architecture.core.design.system.component.NetworkImage
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
import com.example.architecture.core.presentation.ObserveAsEvents
|
||||||
|
import com.example.architecture.core.presentation.asString
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterDetailAction
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterDetailEvent
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterDetailState
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterDetailViewModel
|
||||||
|
import com.example.architecture.feature.characters.presentation.model.CharacterDetailUi
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root: owns the detail ViewModel (Koin supplies it the route's `characterId` via SavedStateHandle),
|
||||||
|
* observes the one-time [CharacterDetailEvent.NavigateBack], and forwards "go back" up the nav stack.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CharacterDetailRoot(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: CharacterDetailViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
ObserveAsEvents(viewModel.events) { event ->
|
||||||
|
when (event) {
|
||||||
|
CharacterDetailEvent.NavigateBack -> onNavigateBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CharacterDetailScreen(state = state, onAction = viewModel::onAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure, stateless screen - previewable without a ViewModel. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CharacterDetailScreen(
|
||||||
|
state: CharacterDetailState,
|
||||||
|
onAction: (CharacterDetailAction) -> Unit,
|
||||||
|
) {
|
||||||
|
AppScaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(state.details?.name ?: stringResource(R.string.character_detail_title))
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onAction(CharacterDetailAction.OnBackClick) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.cd_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
) {
|
||||||
|
val error = state.error
|
||||||
|
val details = state.details
|
||||||
|
when {
|
||||||
|
state.isLoading -> LoadingIndicator()
|
||||||
|
|
||||||
|
// Error wins over any (now-cleared) details so a failed load can't show stale content.
|
||||||
|
error != null -> ErrorState(
|
||||||
|
message = error.asString(),
|
||||||
|
onRetry = { onAction(CharacterDetailAction.OnRetry) },
|
||||||
|
)
|
||||||
|
|
||||||
|
details != null -> CharacterDetailContent(details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CharacterDetailContent(details: CharacterDetailUi) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
NetworkImage(
|
||||||
|
imageUrl = details.imageUrl,
|
||||||
|
contentDescription = stringResource(R.string.cd_character_image, details.name),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(text = details.name, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.background(details.status.indicatorColor(), CircleShape),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(details.status.labelRes()) + " · " + details.species,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
AttributeRow(label = stringResource(R.string.detail_type), value = details.type)
|
||||||
|
AttributeRow(label = stringResource(R.string.detail_gender), value = details.gender)
|
||||||
|
AttributeRow(label = stringResource(R.string.detail_origin), value = details.origin)
|
||||||
|
AttributeRow(label = stringResource(R.string.detail_location), value = details.location)
|
||||||
|
AttributeRow(
|
||||||
|
label = stringResource(R.string.detail_episodes),
|
||||||
|
value = details.episodeCount.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AttributeRow(label: String, value: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val previewDetails = CharacterDetailUi(
|
||||||
|
id = 1,
|
||||||
|
name = "Rick Sanchez",
|
||||||
|
status = CharacterStatus.ALIVE,
|
||||||
|
species = "Human",
|
||||||
|
type = "—",
|
||||||
|
gender = "Male",
|
||||||
|
origin = "Earth (C-137)",
|
||||||
|
location = "Citadel of Ricks",
|
||||||
|
imageUrl = "",
|
||||||
|
episodeCount = 51,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun CharacterDetailScreenLoadedPreview() {
|
||||||
|
AppTheme {
|
||||||
|
CharacterDetailScreen(state = CharacterDetailState(details = previewDetails), onAction = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun CharacterDetailScreenLoadingPreview() {
|
||||||
|
AppTheme {
|
||||||
|
CharacterDetailScreen(state = CharacterDetailState(isLoading = true), onAction = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun CharacterDetailScreenErrorPreview() {
|
||||||
|
AppTheme {
|
||||||
|
CharacterDetailScreen(
|
||||||
|
state = CharacterDetailState(
|
||||||
|
error = com.example.architecture.core.presentation.UiText.DynamicString(
|
||||||
|
"Failed to load character details.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onAction = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation.compose
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.example.architecture.core.design.system.component.AppCard
|
||||||
|
import com.example.architecture.core.design.system.component.AppScaffold
|
||||||
|
import com.example.architecture.core.design.system.component.ErrorState
|
||||||
|
import com.example.architecture.core.design.system.component.LoadingIndicator
|
||||||
|
import com.example.architecture.core.design.system.component.NetworkImage
|
||||||
|
import com.example.architecture.core.design.system.theme.AppTheme
|
||||||
|
import com.example.architecture.core.presentation.ObserveAsEvents
|
||||||
|
import com.example.architecture.core.presentation.asString
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListAction
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListEvent
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListState
|
||||||
|
import com.example.architecture.feature.characters.presentation.CharacterListViewModel
|
||||||
|
import com.example.architecture.feature.characters.presentation.model.CharacterUi
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root: owns the ViewModel (via Koin), observes one-time Events, and forwards navigation up.
|
||||||
|
* The snackbar is resolved with the Context-based [asString] because it runs outside composition.
|
||||||
|
*
|
||||||
|
* [onOpenAbout], [onOpenViewsList] and [onOpenErrorDemo] are renderer-only chrome (a Compose overflow
|
||||||
|
* menu), so they are plain callbacks rather than going through the shared, UI-agnostic ViewModel.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CharacterListRoot(
|
||||||
|
onCharacterClick: (Int) -> Unit,
|
||||||
|
onOpenAbout: () -> Unit,
|
||||||
|
onOpenViewsList: () -> Unit,
|
||||||
|
onOpenErrorDemo: () -> Unit,
|
||||||
|
viewModel: CharacterListViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
ObserveAsEvents(viewModel.events) { event ->
|
||||||
|
when (event) {
|
||||||
|
is CharacterListEvent.NavigateToDetail -> onCharacterClick(event.characterId)
|
||||||
|
is CharacterListEvent.ShowSnackbar -> scope.launch {
|
||||||
|
snackbarHostState.showSnackbar(event.message.asString(context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CharacterListScreen(
|
||||||
|
state = state,
|
||||||
|
onAction = viewModel::onAction,
|
||||||
|
onOpenAbout = onOpenAbout,
|
||||||
|
onOpenViewsList = onOpenViewsList,
|
||||||
|
onOpenErrorDemo = onOpenErrorDemo,
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure, stateless screen - previewable without a ViewModel. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CharacterListScreen(
|
||||||
|
state: CharacterListState,
|
||||||
|
onAction: (CharacterListAction) -> Unit,
|
||||||
|
onOpenAbout: () -> Unit,
|
||||||
|
onOpenViewsList: () -> Unit,
|
||||||
|
onOpenErrorDemo: () -> Unit,
|
||||||
|
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||||
|
) {
|
||||||
|
AppScaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.characters_title)) },
|
||||||
|
actions = {
|
||||||
|
CharacterListOverflowMenu(
|
||||||
|
onOpenAbout = onOpenAbout,
|
||||||
|
onOpenViewsList = onOpenViewsList,
|
||||||
|
onOpenErrorDemo = onOpenErrorDemo,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
) {
|
||||||
|
// Local val so the nullable cross-module `error` can smart-cast inside the branch.
|
||||||
|
val error = state.error
|
||||||
|
when {
|
||||||
|
state.isLoading -> LoadingIndicator()
|
||||||
|
|
||||||
|
error != null && state.characters.isEmpty() -> ErrorState(
|
||||||
|
message = error.asString(),
|
||||||
|
onRetry = { onAction(CharacterListAction.OnRetry) },
|
||||||
|
)
|
||||||
|
|
||||||
|
state.characters.isEmpty() -> EmptyState()
|
||||||
|
|
||||||
|
else -> CharacterList(state = state, onAction = onAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CharacterListOverflowMenu(
|
||||||
|
onOpenAbout: () -> Unit,
|
||||||
|
onOpenViewsList: () -> Unit,
|
||||||
|
onOpenErrorDemo: () -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
IconButton(onClick = { expanded = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = stringResource(R.string.cd_more_options),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_open_as_views)) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onOpenViewsList()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_error_demo)) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onOpenErrorDemo()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_about)) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onOpenAbout()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CharacterList(
|
||||||
|
state: CharacterListState,
|
||||||
|
onAction: (CharacterListAction) -> Unit,
|
||||||
|
) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Trigger paging from the snapshot-backed list state only; the ViewModel guards against
|
||||||
|
// duplicate/just-loading/end-reached requests, so the composable stays simple.
|
||||||
|
val shouldLoadMore by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val layoutInfo = listState.layoutInfo
|
||||||
|
val total = layoutInfo.totalItemsCount
|
||||||
|
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||||
|
total > 0 && lastVisible >= total - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(shouldLoadMore) {
|
||||||
|
if (shouldLoadMore) onAction(CharacterListAction.OnLoadNextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(items = state.characters, key = { it.id }) { character ->
|
||||||
|
CharacterListItem(
|
||||||
|
character = character,
|
||||||
|
onClick = { onAction(CharacterListAction.OnCharacterClick(character.id)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.isLoadingNextPage) {
|
||||||
|
item {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CharacterListItem(
|
||||||
|
character: CharacterUi,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AppCard(modifier = modifier.fillMaxWidth(), onClick = onClick) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
NetworkImage(
|
||||||
|
imageUrl = character.imageUrl,
|
||||||
|
contentDescription = stringResource(R.string.cd_character_avatar, character.name),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(text = character.name, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.background(character.status.indicatorColor(), CircleShape),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(character.status.labelRes()) + " · " + character.species,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyState() {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.characters_empty),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val previewCharacters = persistentListOf(
|
||||||
|
CharacterUi(1, "Rick Sanchez", "Human", "", CharacterStatus.ALIVE),
|
||||||
|
CharacterUi(2, "Morty Smith", "Human", "", CharacterStatus.ALIVE),
|
||||||
|
CharacterUi(3, "Birdperson", "Bird-Person", "", CharacterStatus.DEAD),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun CharacterListScreenLoadedPreview() {
|
||||||
|
AppTheme {
|
||||||
|
CharacterListScreen(
|
||||||
|
state = CharacterListState(characters = previewCharacters),
|
||||||
|
onAction = {},
|
||||||
|
onOpenAbout = {},
|
||||||
|
onOpenViewsList = {},
|
||||||
|
onOpenErrorDemo = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun CharacterListScreenErrorPreview() {
|
||||||
|
AppTheme {
|
||||||
|
CharacterListScreen(
|
||||||
|
state = CharacterListState(
|
||||||
|
error = com.example.architecture.core.presentation.UiText.DynamicString(
|
||||||
|
"No internet connection.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onAction = {},
|
||||||
|
onOpenAbout = {},
|
||||||
|
onOpenViewsList = {},
|
||||||
|
onOpenErrorDemo = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.example.architecture.feature.characters.presentation.compose
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import com.example.architecture.feature.characters.domain.model.CharacterStatus
|
||||||
|
|
||||||
|
/** Shared Compose presentation helpers for [CharacterStatus], used by both the list and detail screens. */
|
||||||
|
@StringRes
|
||||||
|
internal fun CharacterStatus.labelRes(): Int = when (this) {
|
||||||
|
CharacterStatus.ALIVE -> R.string.status_alive
|
||||||
|
CharacterStatus.DEAD -> R.string.status_dead
|
||||||
|
CharacterStatus.UNKNOWN -> R.string.status_unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun CharacterStatus.indicatorColor(): Color = when (this) {
|
||||||
|
CharacterStatus.ALIVE -> Color(0xFF4CAF50)
|
||||||
|
CharacterStatus.DEAD -> Color(0xFFE53935)
|
||||||
|
CharacterStatus.UNKNOWN -> Color(0xFF9E9E9E)
|
||||||
|
}
|
||||||