@mmerterden/multi-agent-pipeline 20.1.0 → 20.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/docs/facts.json +5 -5
- package/manifest.json +52 -31
- package/package.json +1 -1
- package/pipeline/skills/.skill-manifest.json +36 -20
- package/pipeline/skills/.skills-index.json +75 -9
- package/pipeline/skills/shared/README.md +13 -7
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
- package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
- package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
- package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
- package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
- package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
- package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
- package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
- package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
- package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
- package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
- package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
- package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
- package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
- package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
- package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
- package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
- package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
- package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
- package/pipeline/skills/skills-index.md +8 -2
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Android Design Tokens Codegen - Patterns
|
|
2
|
+
|
|
3
|
+
Copy-ready Gradle and Kotlin for the schema-to-typed-code pipeline. Each heading
|
|
4
|
+
mirrors a section in `SKILL.md`. All input is a local JSON schema; all output is
|
|
5
|
+
generated Kotlin under the build directory. Names are illustrative and generic.
|
|
6
|
+
|
|
7
|
+
## Task wrapper
|
|
8
|
+
|
|
9
|
+
A typed task whose only job is declaring inputs/outputs and invoking a generator.
|
|
10
|
+
|
|
11
|
+
```kotlin
|
|
12
|
+
abstract class GenerateFromSchemaTask : DefaultTask() {
|
|
13
|
+
|
|
14
|
+
@get:InputFile
|
|
15
|
+
@get:PathSensitive(PathSensitivity.RELATIVE)
|
|
16
|
+
abstract val schema: RegularFileProperty
|
|
17
|
+
|
|
18
|
+
@get:Classpath
|
|
19
|
+
abstract val generatorClasspath: ConfigurableFileCollection
|
|
20
|
+
|
|
21
|
+
@get:Input
|
|
22
|
+
abstract val packageName: Property<String>
|
|
23
|
+
|
|
24
|
+
@get:Input
|
|
25
|
+
abstract val checkMode: Property<Boolean>
|
|
26
|
+
|
|
27
|
+
@get:OutputDirectory
|
|
28
|
+
abstract val outputDir: DirectoryProperty
|
|
29
|
+
|
|
30
|
+
@get:Inject
|
|
31
|
+
abstract val execOps: ExecOperations
|
|
32
|
+
|
|
33
|
+
@TaskAction
|
|
34
|
+
fun run() {
|
|
35
|
+
execOps.javaexec {
|
|
36
|
+
classpath = generatorClasspath
|
|
37
|
+
mainClass.set("com.example.codegen.MainKt")
|
|
38
|
+
args(
|
|
39
|
+
"--schema", schema.get().asFile.absolutePath,
|
|
40
|
+
"--package", packageName.get(),
|
|
41
|
+
"--out", outputDir.get().asFile.absolutePath,
|
|
42
|
+
)
|
|
43
|
+
if (checkMode.get()) args("--check")
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Registration with a resolvable generator configuration
|
|
50
|
+
|
|
51
|
+
```kotlin
|
|
52
|
+
val codegen: Configuration by configurations.creating { isCanBeConsumed = false }
|
|
53
|
+
|
|
54
|
+
dependencies {
|
|
55
|
+
codegen("com.example:token-codegen:1.4.0")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fun registerGenerator(name: String, schemaPath: String, out: String, pkg: String) =
|
|
59
|
+
tasks.register<GenerateFromSchemaTask>(name) {
|
|
60
|
+
schema.set(layout.projectDirectory.file(schemaPath))
|
|
61
|
+
generatorClasspath.from(codegen)
|
|
62
|
+
packageName.set(pkg)
|
|
63
|
+
outputDir.set(layout.buildDirectory.dir("generated/source/$name"))
|
|
64
|
+
checkMode.set(providers.gradleProperty("codegen.check").isPresent)
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### WorkAction isolation (generator runs in a worker, keeps daemon clean)
|
|
69
|
+
|
|
70
|
+
```kotlin
|
|
71
|
+
interface GenerateParams : WorkParameters {
|
|
72
|
+
val schema: RegularFileProperty
|
|
73
|
+
val outputDir: DirectoryProperty
|
|
74
|
+
val checkMode: Property<Boolean>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
abstract class GenerateAction : WorkAction<GenerateParams> {
|
|
78
|
+
override fun execute() {
|
|
79
|
+
val out = parameters.outputDir.get().asFile
|
|
80
|
+
// parse parameters.schema, emit into out, or compare when checkMode is set
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// inside @TaskAction:
|
|
85
|
+
workerExecutor.classLoaderIsolation { classpath.from(generatorClasspath) }
|
|
86
|
+
.submit(GenerateAction::class.java) {
|
|
87
|
+
schema.set(this@GenerateFromSchemaTask.schema)
|
|
88
|
+
outputDir.set(this@GenerateFromSchemaTask.outputDir)
|
|
89
|
+
checkMode.set(this@GenerateFromSchemaTask.checkMode)
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## preBuild wiring
|
|
94
|
+
|
|
95
|
+
Register every generator's output as a variant generated-source directory and
|
|
96
|
+
make `preBuild` depend on all of them.
|
|
97
|
+
|
|
98
|
+
```kotlin
|
|
99
|
+
val generators = listOf(
|
|
100
|
+
registerGenerator("generateDesignTokens", "schema/tokens.json", "gen/tokens", "com.example.tokens"),
|
|
101
|
+
registerGenerator("generateTestIds", "schema/test-ids.json", "gen/testids", "com.example.testids"),
|
|
102
|
+
registerGenerator("generateAnalytics", "schema/analytics.json", "gen/analytics", "com.example.analytics"),
|
|
103
|
+
registerGenerator("generateDeeplinks", "schema/deeplinks.json", "gen/deeplinks", "com.example.deeplinks"),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
androidComponents.onVariants { variant ->
|
|
107
|
+
generators.forEach { gen ->
|
|
108
|
+
variant.sources.java?.addGeneratedSourceDirectory(gen, GenerateFromSchemaTask::outputDir)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
tasks.named("preBuild").configure { dependsOn(generators) }
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Ordering when a KSP or annotation processor consumes generated types: the
|
|
116
|
+
`addGeneratedSourceDirectory` call already wires KSP/kapt to depend on the
|
|
117
|
+
generator, so generated tokens are visible to processors without extra
|
|
118
|
+
`mustRunAfter`. Only add explicit ordering if a generator reads another
|
|
119
|
+
generator's output, which is an anti-pattern to avoid.
|
|
120
|
+
|
|
121
|
+
## Check mode
|
|
122
|
+
|
|
123
|
+
The check task regenerates into a temp dir and compares, failing on any diff.
|
|
124
|
+
Prefer building this into the generator binary (`--check`) so the same code path
|
|
125
|
+
produces and verifies. A Gradle-side comparison when the binary lacks it:
|
|
126
|
+
|
|
127
|
+
```kotlin
|
|
128
|
+
abstract class VerifyCodegenTask : DefaultTask() {
|
|
129
|
+
|
|
130
|
+
@get:InputDirectory abstract val committed: DirectoryProperty
|
|
131
|
+
@get:InputDirectory abstract val regenerated: DirectoryProperty
|
|
132
|
+
|
|
133
|
+
@TaskAction
|
|
134
|
+
fun verify() {
|
|
135
|
+
val a = committed.get().asFile
|
|
136
|
+
val b = regenerated.get().asFile
|
|
137
|
+
val diffs = a.walkTopDown().filter { it.isFile }.mapNotNull { file ->
|
|
138
|
+
val other = b.resolve(file.relativeTo(a).path)
|
|
139
|
+
if (!other.exists() || other.readText() != file.readText()) file.path else null
|
|
140
|
+
}.toList()
|
|
141
|
+
if (diffs.isNotEmpty()) {
|
|
142
|
+
throw GradleException(
|
|
143
|
+
"Generated code is stale. Run ./gradlew generateDesignTokens generateTestIds " +
|
|
144
|
+
"generateAnalytics generateDeeplinks and commit. Drifted:\n" +
|
|
145
|
+
diffs.joinToString("\n") { " - $it" }
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Wire the aggregate into the verification lifecycle:
|
|
153
|
+
|
|
154
|
+
```kotlin
|
|
155
|
+
val checkCodegenFresh = tasks.register("checkCodegenFresh") {
|
|
156
|
+
dependsOn(generators.map { it.map { t -> t.name } })
|
|
157
|
+
// in committed-output repos, run each generator with -Pcodegen.check
|
|
158
|
+
}
|
|
159
|
+
tasks.named("check").configure { dependsOn(checkCodegenFresh) }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Determinism checklist for the generator
|
|
163
|
+
|
|
164
|
+
- Sort every emitted map/list by a stable key before writing.
|
|
165
|
+
- No timestamps, build numbers, machine names, or absolute paths in output.
|
|
166
|
+
- Fixed formatter (e.g. a pinned KotlinPoet version) and fixed indentation.
|
|
167
|
+
- Stable file naming; one deterministic file per logical group.
|
|
168
|
+
- Same JSON in, byte-identical Kotlin out, on any machine.
|
|
169
|
+
|
|
170
|
+
## Caching
|
|
171
|
+
|
|
172
|
+
```kotlin
|
|
173
|
+
@CacheableTask
|
|
174
|
+
abstract class GenerateFromSchemaTask : DefaultTask() {
|
|
175
|
+
|
|
176
|
+
@get:InputFile
|
|
177
|
+
@get:PathSensitive(PathSensitivity.RELATIVE)
|
|
178
|
+
abstract val schema: RegularFileProperty
|
|
179
|
+
|
|
180
|
+
@get:Classpath
|
|
181
|
+
abstract val generatorClasspath: ConfigurableFileCollection
|
|
182
|
+
|
|
183
|
+
@get:Input
|
|
184
|
+
abstract val generatorVersion: Property<String>
|
|
185
|
+
|
|
186
|
+
@get:OutputDirectory
|
|
187
|
+
abstract val outputDir: DirectoryProperty
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`@Classpath` on the generator artifact and `@Input` on its version mean a
|
|
192
|
+
generator upgrade invalidates the cached output; a schema edit invalidates via
|
|
193
|
+
`@InputFile`; an unchanged build is `UP-TO-DATE` or `FROM-CACHE`.
|
|
194
|
+
|
|
195
|
+
`gradle.properties`:
|
|
196
|
+
|
|
197
|
+
```properties
|
|
198
|
+
org.gradle.caching=true
|
|
199
|
+
org.gradle.configuration-cache=true
|
|
200
|
+
org.gradle.parallel=true
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Generated shapes
|
|
204
|
+
|
|
205
|
+
Illustrative target Kotlin per domain. Structure is the point; the specific
|
|
206
|
+
tokens, tags, events, and routes come from each repo's own schema.
|
|
207
|
+
|
|
208
|
+
### Design tokens (semantic data class, consumed by the Theme layer)
|
|
209
|
+
|
|
210
|
+
```kotlin
|
|
211
|
+
data class SemanticColors(
|
|
212
|
+
val surface: Long,
|
|
213
|
+
val onSurface: Long,
|
|
214
|
+
val accent: Long,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
val lightColors = SemanticColors(surface = 0xFFFFFFFF, onSurface = 0xFF1A1A1A, accent = 0xFF2962FF)
|
|
218
|
+
val darkColors = SemanticColors(surface = 0xFF121212, onSurface = 0xFFEDEDED, accent = 0xFF82B1FF)
|
|
219
|
+
|
|
220
|
+
object Spacing { const val xs = 4; const val sm = 8; const val md = 16; const val lg = 24 }
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### UI-test identifiers (one typed constant per tag)
|
|
224
|
+
|
|
225
|
+
```kotlin
|
|
226
|
+
object TestTags {
|
|
227
|
+
const val PrimaryButton = "primary_button"
|
|
228
|
+
const val EmailField = "email_field"
|
|
229
|
+
const val ErrorBanner = "error_banner"
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// UI: Modifier.testTag(TestTags.PrimaryButton)
|
|
233
|
+
// Test: onNodeWithTag(TestTags.PrimaryButton).assertIsDisplayed()
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Analytics events (name plus typed parameters)
|
|
237
|
+
|
|
238
|
+
```kotlin
|
|
239
|
+
sealed interface AnalyticsEvent {
|
|
240
|
+
val name: String
|
|
241
|
+
val params: Map<String, String>
|
|
242
|
+
|
|
243
|
+
data class ScreenViewed(val screen: String) : AnalyticsEvent {
|
|
244
|
+
override val name = "screen_viewed"
|
|
245
|
+
override val params get() = mapOf("screen" to screen)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
data class ItemSelected(val itemId: String, val position: Int) : AnalyticsEvent {
|
|
249
|
+
override val name = "item_selected"
|
|
250
|
+
override val params get() = mapOf("item_id" to itemId, "position" to position.toString())
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Deeplink manifest (routes plus typed parameters)
|
|
256
|
+
|
|
257
|
+
```kotlin
|
|
258
|
+
enum class Deeplink(val path: String) {
|
|
259
|
+
Home("/home"),
|
|
260
|
+
ItemDetail("/item/{id}"),
|
|
261
|
+
Settings("/settings");
|
|
262
|
+
|
|
263
|
+
companion object {
|
|
264
|
+
fun match(path: String): Deeplink? = entries.firstOrNull { it.matches(path) }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private fun matches(candidate: String): Boolean =
|
|
268
|
+
path.toRegex().matches(candidate) // generated from a stable path template
|
|
269
|
+
}
|
|
270
|
+
```
|
|
@@ -74,6 +74,24 @@ class UserViewModel @Inject constructor(
|
|
|
74
74
|
}
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
+
Reduce state with `.update { copy(...) }` (atomic read-modify-write; safe under concurrent emissions) rather than assigning `_uiState.value = ...`.
|
|
78
|
+
|
|
79
|
+
For a stream-backed screen, expose the state with `.stateIn` and let the first load fire lazily from `onStart` instead of `init {}`. `onStart` runs when the UI actually starts collecting, so a screen that is created but never shown does no work, and the fetch restarts correctly with the subscription.
|
|
80
|
+
|
|
81
|
+
```kotlin
|
|
82
|
+
private val _uiState = MutableStateFlow(UserUiState())
|
|
83
|
+
|
|
84
|
+
val uiState: StateFlow<UserUiState> = _uiState
|
|
85
|
+
.onStart { loadInitialData() }
|
|
86
|
+
.stateIn(
|
|
87
|
+
scope = viewModelScope,
|
|
88
|
+
started = SharingStarted.WhileSubscribed(5_000),
|
|
89
|
+
initialValue = UserUiState()
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Prefer `WhileSubscribed(5_000)` over `Lazily` or `Eagerly`. The 5-second stop timeout keeps the upstream alive across a configuration change (rotation, night-mode toggle) so the flow is not torn down and re-collected for a transient loss of subscribers, while still cancelling upstream work when the screen truly goes away. `Eagerly`/`Lazily` never stop, so they hold upstream resources for the ViewModel's whole life.
|
|
94
|
+
|
|
77
95
|
### 3. Creating the Screen Composable
|
|
78
96
|
|
|
79
97
|
Consume the state in a "Screen" composable and pass data down to stateless components.
|
|
@@ -139,6 +157,50 @@ fun AppNavHost(navController: NavHostController) {
|
|
|
139
157
|
}
|
|
140
158
|
```
|
|
141
159
|
|
|
160
|
+
### Example 2: One-Shot Events (Navigation, Toast, Snackbar)
|
|
161
|
+
|
|
162
|
+
Keep navigation commands, snackbars and toasts out of the state class. State is a value that survives and is re-read on every recomposition, so an event stored in state fires again after a config change (a duplicated navigation, a re-shown snackbar). Model one-shot events as a `Channel` and expose them as a flow.
|
|
163
|
+
|
|
164
|
+
```kotlin
|
|
165
|
+
sealed interface UserEvent {
|
|
166
|
+
data class ShowMessage(val text: String) : UserEvent
|
|
167
|
+
data class NavigateToProfile(val userId: String) : UserEvent
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private val _events = Channel<UserEvent>(Channel.RENDEZVOUS)
|
|
171
|
+
val events: Flow<UserEvent> = _events.receiveAsFlow()
|
|
172
|
+
|
|
173
|
+
fun onSaved(userId: String) {
|
|
174
|
+
viewModelScope.launch {
|
|
175
|
+
_events.send(UserEvent.NavigateToProfile(userId))
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Collect lifecycle-aware so events are only delivered while the screen is at least `STARTED`.
|
|
181
|
+
|
|
182
|
+
```kotlin
|
|
183
|
+
@Composable
|
|
184
|
+
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
|
|
185
|
+
val lifecycleOwner = LocalLifecycleOwner.current
|
|
186
|
+
LaunchedEffect(Unit) {
|
|
187
|
+
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
188
|
+
viewModel.events.collect { event ->
|
|
189
|
+
when (event) {
|
|
190
|
+
is UserEvent.ShowMessage -> snackbarHostState.showSnackbar(event.text)
|
|
191
|
+
is UserEvent.NavigateToProfile -> onNavigateToProfile(event.userId)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// ...
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
A `Channel` with `RENDEZVOUS` capacity buffers events while there is no active collector and hands each to exactly one consumer, so nothing is lost and nothing is replayed. `SharedFlow(replay = 0)` drops any event emitted while the collector is paused (for example during a config change), which loses one-shot events; that is why the channel is the safer default here.
|
|
201
|
+
|
|
202
|
+
For the full MVI base-ViewModel pattern (intent reduction, effect plumbing, a reusable base class), see the `android-mvi-viewmodel` skill.
|
|
203
|
+
|
|
142
204
|
## Best Practices
|
|
143
205
|
|
|
144
206
|
- ✅ **Do:** Use `remember` and `derivedStateOf` to minimize unnecessary calculations during recomposition.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: android-mvi-viewmodel
|
|
3
|
+
description: "A reusable generic BaseViewModel<State, Intent, Event> for MVI Compose apps: StateFlow state, one-shot events over a rendezvous Channel, a single intent dispatch, and a use-case executor with shared loading and error routing. Use when building or reviewing an MVI ViewModel base, intent-to-state reduction, or one-shot navigation and snackbar events."
|
|
4
|
+
risk: safe
|
|
5
|
+
source: multi-agent-pipeline
|
|
6
|
+
date_added: "2026-09-21"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Android MVI BaseViewModel
|
|
10
|
+
|
|
11
|
+
A generic `BaseViewModel<State, Intent, Event>` that every screen ViewModel
|
|
12
|
+
extends. It owns the MVI seam: immutable state as a `StateFlow`, one-shot
|
|
13
|
+
effects as events, a single intent entry point, and centralized use-case
|
|
14
|
+
execution with shared loading and error routing. Targets Kotlin 2.0+,
|
|
15
|
+
`kotlinx.coroutines` 1.8+, and Compose from 2024-2025.
|
|
16
|
+
|
|
17
|
+
Full copy-ready code (the assembled base class, a concrete screen, and the
|
|
18
|
+
lifecycle-aware event collector) lives in
|
|
19
|
+
[references/patterns.md](references/patterns.md). Load that file when you need a
|
|
20
|
+
complete implementation to adapt; each heading there mirrors a section below.
|
|
21
|
+
|
|
22
|
+
## Contents
|
|
23
|
+
|
|
24
|
+
- [The three type parameters](#the-three-type-parameters)
|
|
25
|
+
- [State as StateFlow](#state-as-stateflow)
|
|
26
|
+
- [One-shot events over a rendezvous channel](#one-shot-events-over-a-rendezvous-channel)
|
|
27
|
+
- [Single intent entry point](#single-intent-entry-point)
|
|
28
|
+
- [Use-case executor](#use-case-executor)
|
|
29
|
+
- [Reference-counted loading](#reference-counted-loading)
|
|
30
|
+
- [Active job registry](#active-job-registry)
|
|
31
|
+
- [Collecting state and events in Compose](#collecting-state-and-events-in-compose)
|
|
32
|
+
- [Do's and Don'ts](#dos-and-donts)
|
|
33
|
+
- [Related skills](#related-skills)
|
|
34
|
+
|
|
35
|
+
## The three type parameters
|
|
36
|
+
|
|
37
|
+
Each screen declares three types and passes an initial state up:
|
|
38
|
+
|
|
39
|
+
- `State` - an `@Immutable data class`, the complete render model. One per screen.
|
|
40
|
+
- `Intent` - a `sealed interface` of everything the UI can ask for (clicks,
|
|
41
|
+
text input, retry, refresh).
|
|
42
|
+
- `Event` - a `sealed interface` of one-shot effects the UI consumes exactly
|
|
43
|
+
once (navigate, show snackbar/toast, dismiss keyboard).
|
|
44
|
+
|
|
45
|
+
State is what the screen *is*; an event is something that *happens* once and
|
|
46
|
+
must never replay on rotation.
|
|
47
|
+
|
|
48
|
+
```kotlin
|
|
49
|
+
abstract class BaseViewModel<State, Intent, Event>(
|
|
50
|
+
initialState: State,
|
|
51
|
+
) : ViewModel() {
|
|
52
|
+
protected abstract fun handleIntent(intent: Intent)
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## State as StateFlow
|
|
57
|
+
|
|
58
|
+
State lives in a private `MutableStateFlow` exposed read-only. Never expose the
|
|
59
|
+
mutable flow. Update with `.update { copy(...) }` so the read-modify-write is
|
|
60
|
+
atomic under concurrent coroutines - `value = value.copy()` races.
|
|
61
|
+
|
|
62
|
+
```kotlin
|
|
63
|
+
private val _state = MutableStateFlow(initialState)
|
|
64
|
+
val state: StateFlow<State> = _state.asStateFlow()
|
|
65
|
+
|
|
66
|
+
protected fun reduce(reducer: State.() -> State) {
|
|
67
|
+
_state.update { it.reducer() }
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Reduction is synchronous and pure: `reduce { copy(query = intent.text) }`. No
|
|
72
|
+
suspension, no I/O, no side effects inside the reducer - those belong in the
|
|
73
|
+
use-case executor.
|
|
74
|
+
|
|
75
|
+
## One-shot events over a rendezvous channel
|
|
76
|
+
|
|
77
|
+
Events use a `Channel(Channel.RENDEZVOUS)` exposed as `receiveAsFlow()`, not a
|
|
78
|
+
`SharedFlow(replay = 0)`:
|
|
79
|
+
|
|
80
|
+
```kotlin
|
|
81
|
+
private val _events = Channel<Event>(Channel.RENDEZVOUS)
|
|
82
|
+
val events: Flow<Event> = _events.receiveAsFlow()
|
|
83
|
+
|
|
84
|
+
protected fun sendEvent(event: Event) {
|
|
85
|
+
viewModelScope.launch { _events.send(event) }
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Why rendezvous over `MutableSharedFlow(replay = 0)`:
|
|
90
|
+
|
|
91
|
+
- **No collector, no loss.** `receiveAsFlow` is a cold, single-consumer stream;
|
|
92
|
+
an event emitted while the screen is in the background suspends the sender
|
|
93
|
+
until collection resumes. `SharedFlow.tryEmit` with no subscriber silently
|
|
94
|
+
drops the event - a navigation that never fires.
|
|
95
|
+
- **Delivered once.** A `Channel` fans out to exactly one collector and each
|
|
96
|
+
element is received a single time. `SharedFlow` is multicast, so two active
|
|
97
|
+
collectors (e.g. an accidental double-collect) both fire the navigation.
|
|
98
|
+
- **Back-pressure is correct.** `RENDEZVOUS` (zero buffer) hands off directly;
|
|
99
|
+
the emitter waits for the consumer rather than buffering a queue of stale
|
|
100
|
+
toasts.
|
|
101
|
+
|
|
102
|
+
Use this for navigation, snackbar/toast, and dismiss-keyboard - anything that
|
|
103
|
+
must fire once and must not survive a configuration change.
|
|
104
|
+
|
|
105
|
+
## Single intent entry point
|
|
106
|
+
|
|
107
|
+
The UI calls exactly one method, `processIntent`. The base logs/guards centrally
|
|
108
|
+
and delegates to the subclass `handleIntent`, which is a single `when` over the
|
|
109
|
+
sealed `Intent` - exhaustive, so a new intent fails to compile until handled.
|
|
110
|
+
|
|
111
|
+
```kotlin
|
|
112
|
+
fun processIntent(intent: Intent) = handleIntent(intent)
|
|
113
|
+
|
|
114
|
+
// in a concrete ViewModel
|
|
115
|
+
override fun handleIntent(intent: SearchIntent) = when (intent) {
|
|
116
|
+
is SearchIntent.QueryChanged -> reduce { copy(query = intent.text) }
|
|
117
|
+
SearchIntent.Submit -> runSearch()
|
|
118
|
+
SearchIntent.Retry -> runSearch()
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Use-case executor
|
|
123
|
+
|
|
124
|
+
A single protected helper wraps every suspending call so error handling is not
|
|
125
|
+
copy-pasted per screen. It uses `runCatching`, then **rethrows
|
|
126
|
+
`CancellationException` before any broad catch** - swallowing it breaks
|
|
127
|
+
structured concurrency and leaves the coroutine "running" after cancellation.
|
|
128
|
+
|
|
129
|
+
```kotlin
|
|
130
|
+
protected fun <T> execute(
|
|
131
|
+
showLoading: Boolean = true,
|
|
132
|
+
onError: ((Throwable) -> Unit)? = null,
|
|
133
|
+
block: suspend () -> T,
|
|
134
|
+
onSuccess: (T) -> Unit,
|
|
135
|
+
): Job = viewModelScope.launch {
|
|
136
|
+
if (showLoading) incrementLoading()
|
|
137
|
+
try {
|
|
138
|
+
runCatching { block() }
|
|
139
|
+
.onSuccess(onSuccess)
|
|
140
|
+
.onFailure { throwable ->
|
|
141
|
+
if (throwable is CancellationException) throw throwable
|
|
142
|
+
(onError ?: ::routeError)(throwable)
|
|
143
|
+
}
|
|
144
|
+
} finally {
|
|
145
|
+
if (showLoading) decrementLoading()
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Error routing has two tiers: a per-call `onError` for screen-specific handling,
|
|
151
|
+
falling back to a global `routeError` that maps the throwable to an `Event`
|
|
152
|
+
(snackbar) or an error field in state. Define `routeError` once in the base or
|
|
153
|
+
override per feature. Because `CancellationException` is rethrown before either
|
|
154
|
+
tier, cancellation never reaches the error UI.
|
|
155
|
+
|
|
156
|
+
## Reference-counted loading
|
|
157
|
+
|
|
158
|
+
Overlapping use-cases must share one spinner: if two calls each toggled a
|
|
159
|
+
boolean, the first to finish would hide the indicator while the second still
|
|
160
|
+
runs. Keep an integer count instead - loading is "active" while the count is
|
|
161
|
+
above zero.
|
|
162
|
+
|
|
163
|
+
```kotlin
|
|
164
|
+
private val loadingCount = MutableStateFlow(0)
|
|
165
|
+
val isLoading: StateFlow<Boolean> =
|
|
166
|
+
loadingCount
|
|
167
|
+
.map { it > 0 }
|
|
168
|
+
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
|
169
|
+
|
|
170
|
+
private fun incrementLoading() = loadingCount.update { it + 1 }
|
|
171
|
+
private fun decrementLoading() = loadingCount.update { (it - 1).coerceAtLeast(0) }
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The `execute` helper drives this in its `try/finally`, so the count is balanced
|
|
175
|
+
even when a call throws. Screens observe `isLoading` (or fold it into `State`).
|
|
176
|
+
|
|
177
|
+
## Active job registry
|
|
178
|
+
|
|
179
|
+
Track launched jobs by a key so a screen can cancel in-flight work (a new search
|
|
180
|
+
supersedes the previous one) and so everything is torn down in `onCleared`.
|
|
181
|
+
|
|
182
|
+
```kotlin
|
|
183
|
+
private val jobs = mutableMapOf<String, Job>()
|
|
184
|
+
|
|
185
|
+
protected fun launchTracked(key: String, block: suspend () -> Unit) {
|
|
186
|
+
jobs.remove(key)?.cancel()
|
|
187
|
+
jobs[key] = viewModelScope.launch { block() }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
protected fun cancel(key: String) {
|
|
191
|
+
jobs.remove(key)?.cancel()
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Cancelling `viewModelScope` in `onCleared` (automatic) already stops children;
|
|
196
|
+
the registry adds *selective* cancel and de-duplication. Keying a search by
|
|
197
|
+
`"search"` makes each keystroke cancel the prior request for free.
|
|
198
|
+
|
|
199
|
+
## Collecting state and events in Compose
|
|
200
|
+
|
|
201
|
+
State is collected lifecycle-aware; events are collected in a
|
|
202
|
+
`LaunchedEffect` that respects lifecycle so a background screen never consumes a
|
|
203
|
+
navigation. Pass **state values and lambdas** to child composables - never the
|
|
204
|
+
ViewModel itself, so children stay stateless, previewable, and testable.
|
|
205
|
+
|
|
206
|
+
```kotlin
|
|
207
|
+
@Composable
|
|
208
|
+
fun SearchRoute(viewModel: SearchViewModel = hiltViewModel(), onOpenDetail: (Id) -> Unit) {
|
|
209
|
+
val state by viewModel.state.collectAsStateWithLifecycle()
|
|
210
|
+
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
|
|
211
|
+
|
|
212
|
+
ObserveEvents(viewModel.events) { event ->
|
|
213
|
+
when (event) {
|
|
214
|
+
is SearchEvent.OpenDetail -> onOpenDetail(event.id)
|
|
215
|
+
is SearchEvent.ShowError -> /* show snackbar */ Unit
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
SearchScreen(
|
|
220
|
+
state = state,
|
|
221
|
+
isLoading = isLoading,
|
|
222
|
+
onIntent = viewModel::processIntent,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`ObserveEvents` is a small lifecycle-aware collector (`repeatOnLifecycle`); its
|
|
228
|
+
full body is in [references/patterns.md](references/patterns.md).
|
|
229
|
+
|
|
230
|
+
## Do's and Don'ts
|
|
231
|
+
|
|
232
|
+
- **Do** expose only `asStateFlow()` / `receiveAsFlow()`; keep the mutable
|
|
233
|
+
backing fields private.
|
|
234
|
+
- **Do** update state with `.update { copy(...) }`, never `value = value.copy()`.
|
|
235
|
+
- **Do** rethrow `CancellationException` before any broad `catch` or
|
|
236
|
+
`onFailure`.
|
|
237
|
+
- **Do** route every suspend call through the executor so loading and errors are
|
|
238
|
+
centralized.
|
|
239
|
+
- **Don't** put one-shot effects (navigation, toast) in `State` - they replay on
|
|
240
|
+
rotation.
|
|
241
|
+
- **Don't** use `SharedFlow(replay = 0)` for events; a background screen drops
|
|
242
|
+
them.
|
|
243
|
+
- **Don't** pass the ViewModel to child composables; pass state + lambdas.
|
|
244
|
+
- **Don't** toggle a boolean for loading when calls overlap; count references.
|
|
245
|
+
|
|
246
|
+
## Related skills
|
|
247
|
+
|
|
248
|
+
This skill owns the MVI base-ViewModel seam. For adjacent concerns, defer to:
|
|
249
|
+
|
|
250
|
+
- **`android-architecture`** - module structure, the api/impl split, and
|
|
251
|
+
nav-graph aggregation that host these ViewModels.
|
|
252
|
+
- **`kotlin-coroutines-expert`** - dispatcher selection, structured-concurrency
|
|
253
|
+
and `Flow` operator depth beyond the executor shown here.
|
|
254
|
+
- **`android-datastore`** - persisting the state or preferences a ViewModel
|
|
255
|
+
reads and writes.
|