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)
|
||||
}
|
||||