commit d1ff0e30bab49855a8747818f06b75313031ce59 Author: Adrian Kuta Date: Thu Jun 11 11:03:01 2026 +0200 Initial commit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..44b857f --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d388411 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..938480d --- /dev/null +++ b/README.md @@ -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 { 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("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() } +} + +// :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 # 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 "" # 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. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..464dd0e --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..403053c --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Add project-specific ProGuard rules here. +# Minification is disabled for the release build type in this teaching project. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c10b0e4 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/com/example/architecture/ArchitectureApp.kt b/app/src/main/kotlin/com/example/architecture/ArchitectureApp.kt new file mode 100644 index 0000000..f5fc05c --- /dev/null +++ b/app/src/main/kotlin/com/example/architecture/ArchitectureApp.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/kotlin/com/example/architecture/CharactersViewsRoute.kt b/app/src/main/kotlin/com/example/architecture/CharactersViewsRoute.kt new file mode 100644 index 0000000..a5f3f4c --- /dev/null +++ b/app/src/main/kotlin/com/example/architecture/CharactersViewsRoute.kt @@ -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 diff --git a/app/src/main/kotlin/com/example/architecture/MainActivity.kt b/app/src/main/kotlin/com/example/architecture/MainActivity.kt new file mode 100644 index 0000000..933f289 --- /dev/null +++ b/app/src/main/kotlin/com/example/architecture/MainActivity.kt @@ -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 { + AndroidFragment { fragment -> + fragment.onCharacterClick = { id -> + navController.navigate(CharacterDetailRoute(id)) + } + fragment.onNavigateBack = { navController.popBackStack() } + } + } + } + } + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6917b29 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Android Architecture Showcase + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1a5fe7e --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + +