From 5f2792002b5573d427ac0dff25470e4a6fe6819d Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Wed, 10 Jun 2026 13:44:47 +0200 Subject: [PATCH] REDI-91: MVVM contrast screen (:feature:about:presentation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a deliberately small MVVM About screen: AboutViewModel exposes a StateFlow plus plain public methods (onToggleMvvmNote) with NO Action sealed type and NO Event channel — the explicit contrast to the app's MVI screens. AboutScreen collects state and calls the VM method directly; links open via LocalUriHandler. Static showcase copy lives in the VM as state to demonstrate the StateFlow-as-content shape. aboutGraph + @Serializable AboutRoute + aboutPresentationModule wire it in. A code comment and the README explain why this is MVVM and when each pattern fits. --- feature/about/presentation/build.gradle.kts | 2 + .../about/presentation/AboutNavigation.kt | 21 +++ .../feature/about/presentation/AboutScreen.kt | 140 ++++++++++++++++++ .../feature/about/presentation/AboutState.kt | 22 +++ .../about/presentation/AboutViewModel.kt | 63 ++++++++ .../di/AboutPresentationModule.kt | 10 ++ .../about/presentation/model/AboutLink.kt | 7 + .../src/main/res/values/strings.xml | 8 + 8 files changed, 273 insertions(+) create mode 100644 feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutNavigation.kt create mode 100644 feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutScreen.kt create mode 100644 feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutState.kt create mode 100644 feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/AboutViewModel.kt create mode 100644 feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/di/AboutPresentationModule.kt create mode 100644 feature/about/presentation/src/main/kotlin/com/example/architecture/feature/about/presentation/model/AboutLink.kt create mode 100644 feature/about/presentation/src/main/res/values/strings.xml 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 +