Initial commit
Some checks failed
CI / build (push) Has been cancelled

This commit is contained in:
2026-06-11 11:03:01 +02:00
commit d1ff0e30ba
138 changed files with 5658 additions and 0 deletions

49
.github/workflows/ci.yml vendored Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/build

45
app/build.gradle.kts Normal file
View 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
View File

@@ -0,0 +1,2 @@
# Add project-specific ProGuard rules here.
# Minification is disabled for the release build type in this teaching project.

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

View File

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

View File

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

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Android Architecture Showcase</string>
</resources>

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

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

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

View 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"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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 = {})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
<resources>
<string name="designsystem_retry">Retry</string>
</resources>

View File

@@ -0,0 +1,3 @@
plugins {
alias(libs.plugins.architecture.domain.module)
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -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 = {},
)
}
}

View File

@@ -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(),
)

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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?,
)

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.architecture.domain.module)
}
dependencies {
implementation(project(":core:domain"))
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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?,
)

View File

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

View File

@@ -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",
)
}

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

View File

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

View File

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

View File

@@ -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 = {},
)
}
}

View File

@@ -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 = {},
)
}
}

View File

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

Some files were not shown because too many files have changed in this diff Show More