REDI-91: MVVM contrast screen (:feature:about:presentation)

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.
This commit is contained in:
2026-06-10 13:44:47 +02:00
parent 33de7f5ef8
commit 5f2792002b
8 changed files with 273 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,21 @@
package com.example.architecture.feature.about.presentation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import kotlinx.serialization.Serializable
/** Type-safe route for the About screen. */
@Serializable
data object AboutRoute
/**
* The About feature nav graph. It only needs a "go back" callback — `:app` wires it to the shared
* NavController, keeping this feature decoupled from how it is reached.
*/
fun NavGraphBuilder.aboutGraph(
onNavigateBack: () -> Unit,
) {
composable<AboutRoute> {
AboutRoot(onNavigateBack = onNavigateBack)
}
}

View File

@@ -0,0 +1,140 @@
package com.example.architecture.feature.about.presentation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.architecture.core.design.system.component.AppCard
import com.example.architecture.core.design.system.component.AppScaffold
import com.example.architecture.core.design.system.theme.AppTheme
import com.example.architecture.feature.about.presentation.model.AboutLink
/**
* Root for the MVVM About screen. Note how different the wiring is from an MVI Root: it collects
* [AboutState] and passes the ViewModel's **method reference** straight through — there is no
* `onAction` funnel and no event observation, because this screen has neither.
*/
@Composable
fun AboutRoot(
onNavigateBack: () -> Unit,
viewModel: AboutViewModel = org.koin.androidx.compose.koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
AboutScreen(
state = state,
onToggleMvvmNote = viewModel::onToggleMvvmNote,
onNavigateBack = onNavigateBack,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(
state: AboutState,
onToggleMvvmNote: () -> Unit,
onNavigateBack: () -> Unit,
) {
val uriHandler = LocalUriHandler.current
AppScaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.about_title)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.cd_back),
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(text = state.appName, style = MaterialTheme.typography.headlineSmall)
Text(text = state.description, style = MaterialTheme.typography.bodyLarge)
Text(
text = stringResource(R.string.about_architecture_header),
style = MaterialTheme.typography.titleMedium,
)
state.architectureHighlights.forEach { highlight ->
Text(text = "$highlight", style = MaterialTheme.typography.bodyMedium)
}
// The expandable card is driven entirely by the VM's plain method — the MVVM contrast.
AppCard(
onClick = onToggleMvvmNote,
header = {
Text(
text = stringResource(R.string.about_mvvm_header),
style = MaterialTheme.typography.titleMedium,
)
},
) {
Text(
text = if (state.showMvvmNote) state.mvvmNote else stringResource(R.string.about_mvvm_hint),
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
text = stringResource(R.string.about_links_header),
style = MaterialTheme.typography.titleMedium,
)
state.links.forEach { link ->
TextButton(onClick = { uriHandler.openUri(link.url) }) {
Text(text = link.label)
}
}
}
}
}
@Preview
@Composable
private fun AboutScreenPreview() {
AppTheme {
AboutScreen(
state = AboutState(
appName = "Android Architecture Showcase",
description = "A reference Android app demonstrating a modern multi-module architecture.",
architectureHighlights = listOf(
"Multi-module Clean Architecture.",
"MVI primary, MVVM contrast.",
),
mvvmNote = "MVI funnels intents through onAction; this screen uses plain VM methods.",
showMvvmNote = true,
links = listOf(AboutLink("GitHub repository", "https://example.com")),
),
onToggleMvvmNote = {},
onNavigateBack = {},
)
}
}

View File

@@ -0,0 +1,22 @@
package com.example.architecture.feature.about.presentation
import androidx.compose.runtime.Stable
import com.example.architecture.feature.about.presentation.model.AboutLink
/**
* State for the MVVM About screen.
*
* Contrast with the MVI [com.example.architecture.feature.characters.presentation.CharacterListState]:
* that one is UI-agnostic and stays Compose-free by using `ImmutableList`. This module is a
* Compose-only presentation layer, so it simply annotates the state `@Stable` (cheaper than pulling
* in kotlinx-collections-immutable) to keep the `List` fields from defeating recomposition skipping.
*/
@Stable
data class AboutState(
val appName: String = "",
val description: String = "",
val architectureHighlights: List<String> = emptyList(),
val mvvmNote: String = "",
val showMvvmNote: Boolean = false,
val links: List<AboutLink> = emptyList(),
)

View File

@@ -0,0 +1,63 @@
package com.example.architecture.feature.about.presentation
import androidx.lifecycle.ViewModel
import com.example.architecture.feature.about.presentation.model.AboutLink
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* **MVVM — the deliberate contrast to the app's MVI screens.**
*
* There is no `Action` sealed type and no `Event`/effect `Channel`. The screen reads [state] and
* invokes the ViewModel's **plain public methods** directly. That is the whole point of this screen:
* for small, mostly-static UI, the MVI ceremony (single `onAction` funnel + one-time event channel)
* isn't worth it. See [AboutState] for the matching stability note, and the in-app "Why is this
* screen MVVM?" card / the README for when to pick each pattern.
*
* The showcase copy lives here as state (rather than in string resources) precisely to demonstrate
* the "StateFlow holds the content" MVVM shape; real localizable product copy would use resources.
*/
class AboutViewModel : ViewModel() {
private val _state = MutableStateFlow(
AboutState(
appName = "Android Architecture Showcase",
description = "A reference Android app that demonstrates a modern, multi-module " +
"architecture: feature-layered Clean Architecture, a typed networking + error stack, " +
"and a single presentation layer rendered by two different UI toolkits.",
architectureHighlights = listOf(
"Multi-module, feature-layered Clean Architecture (presentation → domain ← data).",
"Gradle convention plugins with a single version catalog as the source of truth.",
"MVI is the primary pattern; this About screen is the MVVM contrast.",
"One UI-agnostic ViewModel rendered by both Jetpack Compose and classic Android Views.",
"Koin for DI, Ktor for networking, type-safe Compose Navigation, Coil for images.",
"Typed Result / DataError handling surfaced to the UI as UiText.",
),
mvvmNote = "MVI funnels every user intent through a single onAction(Action) entry point " +
"and emits one-time effects (navigation, snackbars) through an Event channel. That " +
"structure pays off when state is complex and interacting — like the paginated, " +
"process-death-restorable characters list. This screen is intentionally MVVM instead: " +
"the ViewModel exposes a StateFlow plus plain public methods (onToggleMvvmNote), with " +
"no Action or Event types at all. Rule of thumb: reach for MVI when state is complex " +
"and side effects matter; reach for MVVM when the screen is small and mostly static.",
links = listOf(
AboutLink(
label = "GitHub repository",
url = "https://github.com/AdrianKuta/android-architecture-showcase",
),
AboutLink(
label = "Rick & Morty API (data source)",
url = "https://rickandmortyapi.com",
),
),
),
)
val state: StateFlow<AboutState> = _state.asStateFlow()
/** MVVM: a plain public method mutates state directly — no Action object, no reducer funnel. */
fun onToggleMvvmNote() {
_state.update { it.copy(showMvvmNote = !it.showMvvmNote) }
}
}

View File

@@ -0,0 +1,10 @@
package com.example.architecture.feature.about.presentation.di
import com.example.architecture.feature.about.presentation.AboutViewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
/** Presentation DI for the About feature. */
val aboutPresentationModule = module {
viewModelOf(::AboutViewModel)
}

View File

@@ -0,0 +1,7 @@
package com.example.architecture.feature.about.presentation.model
/** A labelled external link shown on the About screen. */
data class AboutLink(
val label: String,
val url: String,
)

View File

@@ -0,0 +1,8 @@
<resources>
<string name="about_title">About</string>
<string name="about_architecture_header">Architecture highlights</string>
<string name="about_mvvm_header">Why is this screen MVVM?</string>
<string name="about_mvvm_hint">Tap to see how this MVVM screen differs from the app\'s MVI screens.</string>
<string name="about_links_header">Links</string>
<string name="cd_back">Back</string>
</resources>