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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user