diff --git a/README.md b/README.md index 8d6c5fb..febd59f 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,21 @@ be minimal but complete and idiomatic. > **Status:** built milestone-by-milestone from the > [Linear backlog](https://linear.app/adrian-kuta/project/android-architecture-showcase-b5ecdeddda6c). -> **Foundation** (scaffold, version catalog, `build-logic` convention plugins) is complete and the -> project assembles green. Full architecture docs land with the *Quality & Docs* milestone. +> **Foundation**, **Core Infrastructure**, the **Flagship MVI** characters feature, and +> **Breadth & Contrast** (character detail, the MVVM About screen, the Views renderer, and +> Compose↔View interop) are complete and the project assembles green. Full architecture docs land +> with the *Quality & Docs* milestone. ## Stack Multi-module Gradle + `build-logic` convention plugins · Koin (constructor DSL) · Ktor · -KotlinX Serialization · Coil · Kermit · type-safe Compose Navigation. Data comes from the no-key +KotlinX Serialization · Coil · Timber · type-safe Compose Navigation. Data comes from the no-key [Rick & Morty API](https://rickandmortyapi.com/). -What it will showcase: **MVI** as the primary presentation pattern (flagship *characters* feature), -an **MVVM** contrast screen, and the same MVI `ViewModel` driven by **two renderers** — Jetpack -Compose and classic **XML + ViewBinding + RecyclerView** — proving the presentation logic is -UI-toolkit-agnostic. +What it showcases: **MVI** as the primary presentation pattern (flagship *characters* feature), +an **MVVM** contrast screen (*about*), and the same MVI `ViewModel` driven by **two renderers** — +Jetpack Compose and classic **XML + ViewBinding + RecyclerView** — proving the presentation logic is +UI-toolkit-agnostic. See [Presentation patterns](#presentation-patterns-mvi-vs-mvvm) below. ## Module structure @@ -40,6 +42,36 @@ UI-toolkit-agnostic. **Dependency rules:** `presentation → domain ← data`; `domain` depends only on `:core:domain`; features never depend on other features; `:app` wires the graph. +## Presentation patterns (MVI vs MVVM) + +Both patterns live side by side so the trade-off is concrete, not theoretical. + +| | **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` directly | +| Best when | state is complex and interacting; effects matter | the screen is small and mostly static | + +The flagship characters list is MVI because its state is genuinely complex — pagination, loading +vs. next-page loading, error surfacing, and `SavedStateHandle` restore after process death — and it +emits navigation/snackbar effects. The About screen 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. Rule of thumb: **reach for MVI when state is complex and side +effects matter; reach for MVVM when the screen is simple.** + +### One ViewModel, two renderers + +`:feature:characters:presentation` is **UI-toolkit-agnostic** — it has no Compose *and* no Views +dependency (state stays Compose-stable via `kotlinx-collections-immutable` rather than `@Stable`). +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`. + +`: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. + ## Build & run ```bash diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fd105f7..cb519c4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,8 @@ 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 { @@ -16,16 +18,23 @@ dependencies { implementation(project(":core:data")) implementation(project(":core:design-system")) - // Characters feature: data + presentation (Koin modules) + Compose renderer (nav graph). + // 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. diff --git a/app/src/main/kotlin/com/example/architecture/ArchitectureApp.kt b/app/src/main/kotlin/com/example/architecture/ArchitectureApp.kt index 6842088..f5fc05c 100644 --- a/app/src/main/kotlin/com/example/architecture/ArchitectureApp.kt +++ b/app/src/main/kotlin/com/example/architecture/ArchitectureApp.kt @@ -2,6 +2,7 @@ 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 @@ -31,6 +32,8 @@ class ArchitectureApp : Application() { // 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..6397732 --- /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 index bbeab40..933f289 100644 --- a/app/src/main/kotlin/com/example/architecture/MainActivity.kt +++ b/app/src/main/kotlin/com/example/architecture/MainActivity.kt @@ -1,16 +1,30 @@ package com.example.architecture import android.os.Bundle -import androidx.activity.ComponentActivity 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 -class MainActivity : ComponentActivity() { +/** + * 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() @@ -22,8 +36,23 @@ class MainActivity : ComponentActivity() { startDestination = CharacterListRoute, ) { charactersGraph( - onCharacterClick = { /* Detail navigation is wired in the next milestone. */ }, + 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/feature/about/presentation/build.gradle.kts b/feature/about/presentation/build.gradle.kts index 444397e..25882c0 100644 --- a/feature/about/presentation/build.gradle.kts +++ b/feature/about/presentation/build.gradle.kts @@ -1,5 +1,7 @@ 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, diff --git a/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutNavigation.kt b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutNavigation.kt new file mode 100644 index 0000000..9462541 --- /dev/null +++ b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutNavigation.kt @@ -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 { + AboutRoot(onNavigateBack = onNavigateBack) + } +} diff --git a/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutScreen.kt b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutScreen.kt new file mode 100644 index 0000000..daf2caa --- /dev/null +++ b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutScreen.kt @@ -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 = {}, + ) + } +} diff --git a/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutState.kt b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutState.kt new file mode 100644 index 0000000..5d4f4de --- /dev/null +++ b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutState.kt @@ -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 = emptyList(), + val mvvmNote: String = "", + val showMvvmNote: Boolean = false, + val links: List = emptyList(), +) diff --git a/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutViewModel.kt b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutViewModel.kt new file mode 100644 index 0000000..3543016 --- /dev/null +++ b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutViewModel.kt @@ -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 = _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) } + } +} diff --git a/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/di/AboutPresentationModule.kt b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/di/AboutPresentationModule.kt new file mode 100644 index 0000000..8732ed9 --- /dev/null +++ b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/di/AboutPresentationModule.kt @@ -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) +} diff --git a/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/model/AboutLink.kt b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/model/AboutLink.kt new file mode 100644 index 0000000..9e9cff7 --- /dev/null +++ b/feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/model/AboutLink.kt @@ -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, +) diff --git a/feature/about/presentation/src/main/res/values/strings.xml b/feature/about/presentation/src/main/res/values/strings.xml new file mode 100644 index 0000000..46a6616 --- /dev/null +++ b/feature/about/presentation/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + About + Architecture highlights + Why is this screen MVVM? + Tap to see how this MVVM screen differs from the app\'s MVI screens. + Links + Back + diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterDetailScreen.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterDetailScreen.kt new file mode 100644 index 0000000..db46a47 --- /dev/null +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterDetailScreen.kt @@ -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 = {}, + ) + } +} diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt index 091ec3e..b342f25 100644 --- a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterListScreen.kt @@ -3,8 +3,8 @@ 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.PaddingValues 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 @@ -14,8 +14,14 @@ 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 @@ -24,13 +30,14 @@ 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.LaunchedEffect +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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -58,10 +65,15 @@ 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] and [onOpenViewsList] 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, viewModel: CharacterListViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -81,6 +93,8 @@ fun CharacterListRoot( CharacterListScreen( state = state, onAction = viewModel::onAction, + onOpenAbout = onOpenAbout, + onOpenViewsList = onOpenViewsList, snackbarHostState = snackbarHostState, ) } @@ -91,10 +105,17 @@ fun CharacterListRoot( fun CharacterListScreen( state: CharacterListState, onAction: (CharacterListAction) -> Unit, + onOpenAbout: () -> Unit, + onOpenViewsList: () -> Unit, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { AppScaffold( - topBar = { TopAppBar(title = { Text(stringResource(R.string.characters_title)) }) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.characters_title)) }, + actions = { CharacterListOverflowMenu(onOpenAbout = onOpenAbout, onOpenViewsList = onOpenViewsList) }, + ) + }, ) { innerPadding -> Box( modifier = Modifier @@ -124,6 +145,36 @@ fun CharacterListScreen( } } +@Composable +private fun CharacterListOverflowMenu( + onOpenAbout: () -> Unit, + onOpenViewsList: () -> 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_about)) }, + onClick = { + expanded = false + onOpenAbout() + }, + ) + } +} + @Composable private fun CharacterList( state: CharacterListState, @@ -148,7 +199,7 @@ private fun CharacterList( LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { items(items = state.characters, key = { it.id }) { character -> @@ -218,18 +269,6 @@ private fun EmptyState() { } } -private fun CharacterStatus.labelRes(): Int = when (this) { - CharacterStatus.ALIVE -> R.string.status_alive - CharacterStatus.DEAD -> R.string.status_dead - CharacterStatus.UNKNOWN -> R.string.status_unknown -} - -private fun CharacterStatus.indicatorColor(): Color = when (this) { - CharacterStatus.ALIVE -> Color(0xFF4CAF50) - CharacterStatus.DEAD -> Color(0xFFE53935) - CharacterStatus.UNKNOWN -> Color(0xFF9E9E9E) -} - private val previewCharacters = persistentListOf( CharacterUi(1, "Rick Sanchez", "Human", "", CharacterStatus.ALIVE), CharacterUi(2, "Morty Smith", "Human", "", CharacterStatus.ALIVE), @@ -243,6 +282,8 @@ private fun CharacterListScreenLoadedPreview() { CharacterListScreen( state = CharacterListState(characters = previewCharacters), onAction = {}, + onOpenAbout = {}, + onOpenViewsList = {}, ) } } @@ -258,6 +299,8 @@ private fun CharacterListScreenErrorPreview() { ), ), onAction = {}, + onOpenAbout = {}, + onOpenViewsList = {}, ) } } diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterStatusUi.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterStatusUi.kt new file mode 100644 index 0000000..f67dc87 --- /dev/null +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharacterStatusUi.kt @@ -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) +} diff --git a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt index 3ae9849..2e6c5a3 100644 --- a/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt +++ b/feature/characters/presentation-compose/src/main/kotlin/com/example/architecture/feature/characters/presentation/compose/CharactersNavigation.kt @@ -1,5 +1,6 @@ package com.example.architecture.feature.characters.presentation.compose +import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import kotlinx.serialization.Serializable @@ -8,14 +9,33 @@ import kotlinx.serialization.Serializable @Serializable data object CharacterListRoute +/** Type-safe route for the character detail screen — carries only the typed id, never an object. */ +@Serializable +data class CharacterDetailRoute(val characterId: Int) + /** - * The characters feature nav graph. `:app` only calls this and supplies cross-screen navigation as - * a callback. The detail destination is added here in a later milestone. + * The characters feature nav graph. List→detail is intra-feature navigation, so it is driven by the + * [navController] passed in. Cross-boundary destinations (the About screen, the Views renderer hosted + * by `:app`) stay decoupled as callbacks supplied by `:app`. */ fun NavGraphBuilder.charactersGraph( - onCharacterClick: (Int) -> Unit, + navController: NavController, + onOpenAbout: () -> Unit, + onOpenViewsList: () -> Unit, ) { composable { - CharacterListRoot(onCharacterClick = onCharacterClick) + CharacterListRoot( + onCharacterClick = { characterId -> + navController.navigate(CharacterDetailRoute(characterId)) + }, + onOpenAbout = onOpenAbout, + onOpenViewsList = onOpenViewsList, + ) + } + composable { + // The typed CharacterDetailRoute serializes `characterId` into the destination's arguments, + // which Navigation copies into the ViewModel's SavedStateHandle — that is where + // CharacterDetailViewModel reads it (keeping that module free of any navigation dependency). + CharacterDetailRoot(onNavigateBack = { navController.popBackStack() }) } } diff --git a/feature/characters/presentation-compose/src/main/res/values/strings.xml b/feature/characters/presentation-compose/src/main/res/values/strings.xml index 0b9f957..c4c5132 100644 --- a/feature/characters/presentation-compose/src/main/res/values/strings.xml +++ b/feature/characters/presentation-compose/src/main/res/values/strings.xml @@ -5,4 +5,19 @@ Alive Dead Unknown + + + More options + About + Open as Views + + + Character + Back + Image of %1$s + Type + Gender + Origin + Location + Episodes diff --git a/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListAdapter.kt b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListAdapter.kt new file mode 100644 index 0000000..6e116e9 --- /dev/null +++ b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListAdapter.kt @@ -0,0 +1,74 @@ +package com.example.architecture.feature.characters.presentation.views + +import android.content.res.ColorStateList +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil3.load +import coil3.request.crossfade +import coil3.request.transformations +import coil3.transform.CircleCropTransformation +import com.example.architecture.feature.characters.presentation.model.CharacterUi +import com.example.architecture.feature.characters.presentation.views.databinding.ItemCharacterBinding + +/** + * RecyclerView adapter over the SAME [CharacterUi] presentation model the Compose renderer uses. + * [DiffUtil] computes minimal updates; Coil loads avatars straight into the `ImageView`. + */ +internal class CharacterListAdapter( + private val onItemClick: (Int) -> Unit, +) : ListAdapter(DIFF_CALLBACK) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CharacterViewHolder { + val binding = ItemCharacterBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return CharacterViewHolder(binding) + } + + override fun onBindViewHolder(holder: CharacterViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class CharacterViewHolder( + private val binding: ItemCharacterBinding, + ) : RecyclerView.ViewHolder(binding.root) { + + init { + binding.root.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + onItemClick(getItem(position).id) + } + } + } + + fun bind(item: CharacterUi) { + val context = binding.root.context + binding.name.text = item.name + binding.statusSpecies.text = + context.getString(item.status.labelRes()) + " · " + item.species + ViewCompat.setBackgroundTintList( + binding.statusDot, + ColorStateList.valueOf(item.status.indicatorColor()), + ) + binding.avatar.contentDescription = + context.getString(R.string.cd_character_avatar, item.name) + binding.avatar.load(item.imageUrl) { + crossfade(true) + transformations(CircleCropTransformation()) + } + } + } + + private companion object { + val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CharacterUi, newItem: CharacterUi): Boolean = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: CharacterUi, newItem: CharacterUi): Boolean = + oldItem == newItem + } + } +} diff --git a/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListFragment.kt b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListFragment.kt new file mode 100644 index 0000000..a7543a0 --- /dev/null +++ b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterListFragment.kt @@ -0,0 +1,127 @@ +package com.example.architecture.feature.characters.presentation.views + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.example.architecture.core.presentation.asString +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.views.databinding.FragmentCharacterListBinding +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.viewModel + +/** + * Classic Views renderer for the characters list. It drives the **same** [CharacterListViewModel] as + * the Compose screen — proving the presentation logic (State/Action/Event/UI-model) is truly + * UI-agnostic. Koin's `by viewModel()` supplies the VM (and its `SavedStateHandle`). + * + * `:app` (the interop owner) wires [onCharacterClick] / [onNavigateBack]; the Fragment never touches + * the Compose NavController, so this module stays decoupled from navigation. + */ +class CharacterListFragment : Fragment() { + + var onCharacterClick: (Int) -> Unit = {} + var onNavigateBack: () -> Unit = {} + + private var _binding: FragmentCharacterListBinding? = null + private val binding get() = _binding!! + + private val viewModel: CharacterListViewModel by viewModel() + + private lateinit var listAdapter: CharacterListAdapter + private var pagingListener: RecyclerView.OnScrollListener? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentCharacterListBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + listAdapter = CharacterListAdapter( + onItemClick = { id -> viewModel.onAction(CharacterListAction.OnCharacterClick(id)) }, + ) + val scrollListener = pagingScrollListener() + pagingListener = scrollListener + binding.recyclerView.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = listAdapter + addOnScrollListener(scrollListener) + } + binding.toolbar.setNavigationOnClickListener { onNavigateBack() } + binding.retryButton.setOnClickListener { + viewModel.onAction(CharacterListAction.OnRetry) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.state.collect(::render) } + launch { viewModel.events.collect(::handleEvent) } + } + } + } + + private fun render(state: CharacterListState) { + listAdapter.submitList(state.characters) + + val showFullScreenError = state.error != null && state.characters.isEmpty() + binding.progressBar.isVisible = state.isLoading + binding.nextPageProgress.isVisible = state.isLoadingNextPage + binding.errorContainer.isVisible = showFullScreenError + binding.recyclerView.isVisible = !state.isLoading && !showFullScreenError + + if (showFullScreenError) { + binding.errorMessage.text = state.error?.asString(requireContext()) + } + } + + private fun handleEvent(event: CharacterListEvent) { + when (event) { + is CharacterListEvent.NavigateToDetail -> onCharacterClick(event.characterId) + is CharacterListEvent.ShowSnackbar -> Snackbar.make( + binding.root, + event.message.asString(requireContext()), + Snackbar.LENGTH_SHORT, + ).show() + } + } + + private fun pagingScrollListener() = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return + val lastVisible = layoutManager.findLastVisibleItemPosition() + // `lastVisible >= 0` skips the empty-list case (findLastVisibleItemPosition() == -1), + // mirroring the Compose renderer's `total > 0` guard. The ViewModel still guards against + // duplicate / end-reached / already-loading requests. + if (lastVisible >= 0 && lastVisible >= layoutManager.itemCount - 1) { + viewModel.onAction(CharacterListAction.OnLoadNextPage) + } + } + } + + override fun onDestroyView() { + // Remove the scroll listener and detach the adapter before nulling the binding so neither + // the RecyclerView nor this Fragment is leaked. + pagingListener?.let { binding.recyclerView.removeOnScrollListener(it) } + pagingListener = null + binding.recyclerView.adapter = null + super.onDestroyView() + _binding = null + } +} diff --git a/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterStatusViews.kt b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterStatusViews.kt new file mode 100644 index 0000000..23d7f80 --- /dev/null +++ b/feature/characters/presentation-views/src/main/kotlin/com/example/architecture/feature/characters/presentation/views/CharacterStatusViews.kt @@ -0,0 +1,24 @@ +package com.example.architecture.feature.characters.presentation.views + +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import com.example.architecture.feature.characters.domain.model.CharacterStatus + +/** + * Views-renderer presentation helpers for [CharacterStatus]. These intentionally mirror the Compose + * renderer's helpers but return platform types (a string-res id and an ARGB Int) — each renderer + * owns its own resources, so the small label duplication across modules is expected. + */ +@StringRes +internal fun CharacterStatus.labelRes(): Int = when (this) { + CharacterStatus.ALIVE -> R.string.characters_views_status_alive + CharacterStatus.DEAD -> R.string.characters_views_status_dead + CharacterStatus.UNKNOWN -> R.string.characters_views_status_unknown +} + +@ColorInt +internal fun CharacterStatus.indicatorColor(): Int = when (this) { + CharacterStatus.ALIVE -> 0xFF4CAF50.toInt() + CharacterStatus.DEAD -> 0xFFE53935.toInt() + CharacterStatus.UNKNOWN -> 0xFF9E9E9E.toInt() +} diff --git a/feature/characters/presentation-views/src/main/res/drawable/bg_status_dot.xml b/feature/characters/presentation-views/src/main/res/drawable/bg_status_dot.xml new file mode 100644 index 0000000..8eb9180 --- /dev/null +++ b/feature/characters/presentation-views/src/main/res/drawable/bg_status_dot.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/feature/characters/presentation-views/src/main/res/drawable/ic_arrow_back.xml b/feature/characters/presentation-views/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..9cac029 --- /dev/null +++ b/feature/characters/presentation-views/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,11 @@ + + + diff --git a/feature/characters/presentation-views/src/main/res/layout/fragment_character_list.xml b/feature/characters/presentation-views/src/main/res/layout/fragment_character_list.xml new file mode 100644 index 0000000..57f148f --- /dev/null +++ b/feature/characters/presentation-views/src/main/res/layout/fragment_character_list.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + +