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 {
|
plugins {
|
||||||
alias(libs.plugins.architecture.android.feature)
|
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,
|
// 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