@code-migration/wow-migrator 0.2.3 → 0.2.4
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/package.json +1 -1
- package/skills/android-to-kmp-migrator/SKILL.md +20 -1
- package/skills/android-to-kmp-migrator/bind.md +4 -0
- package/skills/android-to-kmp-migrator/output-contract.md +29 -2
- package/skills/android-to-kmp-migrator/references/kmp-expert.md +260 -0
- package/skills/android-to-kmp-migrator/references/kmp-mvi-flowredux.md +344 -0
- package/skills/android-to-kmp-migrator/references/kmp-mvvm.md +328 -0
- package/skills/android-to-kmp-migrator/roles/global-migration-phase.md +7 -2
- package/skills/android-to-kmp-migrator/roles/migration-planning-gate.md +6 -2
- package/skills/android-to-kmp-migrator/roles/migration-prep.md +6 -2
- package/skills/android-to-kmp-migrator/roles/module-implementation.md +20 -2
- package/skills/android-to-kmp-migrator/roles/module-node-review-fix.md +18 -2
- package/skills/android-to-kmp-migrator/roles/target-project-assistant.md +10 -1
- package/skills/android-to-kmp-migrator/workflow.md +12 -2
package/package.json
CHANGED
|
@@ -63,7 +63,7 @@ Module-first migrator for Legacy Android → KMP target assembly. **Upstream ana
|
|
|
63
63
|
|
|
64
64
|
## Protocol Summary
|
|
65
65
|
|
|
66
|
-
0. Pre-flight — [dependencies.yaml](dependencies.yaml): `rg` / `git` / `curl`, optional `jetbrains` MCP (`optional_mcp`), upstream analyst **P6** (`upstream_inputs`); record `dependency_preflight` in `run_manifest.json`.
|
|
66
|
+
0. Pre-flight — [dependencies.yaml](dependencies.yaml): `rg` / `git` / `curl`, optional `jetbrains` MCP (`optional_mcp`), upstream analyst **P6** (`upstream_inputs`); **identify `design_mode` from user input (default `mvi`)**; record `dependency_preflight` and `design_mode` in `run_manifest.json`.
|
|
67
67
|
1. Verify analyst **P6**; `run_manifest.json`, `upstream_analyst_index.json`.
|
|
68
68
|
2. Migration inventory + `modules_migration_index.json`.
|
|
69
69
|
3. Workspace state init.
|
|
@@ -73,6 +73,23 @@ Module-first migrator for Legacy Android → KMP target assembly. **Upstream ana
|
|
|
73
73
|
7. Global representation + completion-report `report` mode.
|
|
74
74
|
8. **kmp-test-validator** — **mandatory** when **V0** ready (MG17).
|
|
75
75
|
|
|
76
|
+
## Design Mode (architecture pattern)
|
|
77
|
+
|
|
78
|
+
The migrator targets one presentation architecture per run, selected at **pre-flight (Step 0)** by the Leader from the **user input**, then frozen for the whole run and recorded in `run_manifest.json` → `design_mode`.
|
|
79
|
+
|
|
80
|
+
| `design_mode` | Architecture reference | When chosen |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `mvi` **(default)** | [references/kmp-mvi-flowredux.md](references/kmp-mvi-flowredux.md) | Default when user input gives no clear signal; or user mentions MVI, FlowRedux, state machine, reducer, intent, unidirectional, sealed `State`/`Action`, `dispatch`, `inState`, `onEnter` |
|
|
83
|
+
| `mvvm` | [references/kmp-mvvm.md](references/kmp-mvvm.md) | User mentions MVVM, shared `ViewModel`, `StateFlow`/`uiState`, `viewModelScope`, `collectAsStateWithLifecycle`, KMP-ObservableViewModel, SKIE |
|
|
84
|
+
|
|
85
|
+
Both modes also follow [references/kmp-expert.md](references/kmp-expert.md) for base KMP/CMP conventions.
|
|
86
|
+
|
|
87
|
+
**Rules**:
|
|
88
|
+
- **Default is `mvi`** — when the user input contains no explicit or implied architecture signal, the Leader MUST select `mvi`.
|
|
89
|
+
- Record the decision as `design_mode: { value, source: "user_input | default", signals: [] }` in `run_manifest.json` at **MG0**.
|
|
90
|
+
- The Leader passes `design_mode` and the resolved `architecture_reference_path` to every architecture-producing role (planning-gate, prep, module-implementation, module-node-review-fix, global-migration-phase) and to TPA for target-pattern detection.
|
|
91
|
+
- `design_mode` is **fixed for the run**; a mid-run change requires a fresh run, not in-place mutation.
|
|
92
|
+
|
|
76
93
|
## Skill Chain (mandatory)
|
|
77
94
|
|
|
78
95
|
```text
|
|
@@ -108,6 +125,7 @@ android-project-analyst (P6) → android-to-kmp-migrator (M0–V0) → kmp-test-
|
|
|
108
125
|
| [bind.md](bind.md) | Limits, constraints, failures |
|
|
109
126
|
| [dependencies.yaml](dependencies.yaml) | CLI + optional MCP per role |
|
|
110
127
|
| [roles/](roles/) | Role specs |
|
|
128
|
+
| [references/](references/) | Architecture references: `kmp-mvi-flowredux.md` (MVI, default), `kmp-mvvm.md` (MVVM), `kmp-expert.md` (base KMP/CMP) |
|
|
111
129
|
|
|
112
130
|
## Handoff Gates
|
|
113
131
|
|
|
@@ -123,6 +141,7 @@ android-project-analyst (P6) → android-to-kmp-migrator (M0–V0) → kmp-test-
|
|
|
123
141
|
## Shared Rules
|
|
124
142
|
|
|
125
143
|
- **Skill chain**: `android-project-analyst` **P6** before migrator; `kmp-test-validator` **after** migrator **V0** — both mandatory.
|
|
144
|
+
- **Design mode**: identified from user input at pre-flight, **default `mvi`**; frozen for the run; architecture-producing roles MUST follow the resolved `architecture_reference_path` (`kmp-mvi-flowredux.md` for `mvi`, `kmp-mvvm.md` for `mvvm`).
|
|
126
145
|
- **Target KMP edit mandate**: after analyst P6 understanding, migrator roles MUST create or update production files under `kmp_target_project_path`. Planning-only or artifact-only completion is invalid.
|
|
127
146
|
- **Roles that edit target** (record `changed_files[]` or `integration_changed_files[]`):
|
|
128
147
|
- `migration-prep` — optional scaffold edits (theme, resources, routes, models) when planning allows
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
- **Skill chain (mandatory)**:
|
|
30
30
|
- **Before migrator**: `android-project-analyst` MUST finish and produce package **P6** (`handoff_gates.P6.ready = true`). If P6 is missing or stale, return `blocked` and dispatch analyst — do not start migrator nodes.
|
|
31
31
|
- **After migrator**: when package **V0** is ready, Leader MUST invoke `kmp-test-validator` (MG17). Migrator completion without validator dispatch is invalid.
|
|
32
|
+
- **Design mode**: at pre-flight the Leader identifies `design_mode` from user input — **default `mvi`** when no architecture signal is present. It is recorded in `run_manifest.json`, **frozen for the run**, and every architecture-producing role MUST follow the resolved `architecture_reference_path` (`references/kmp-mvi-flowredux.md` for `mvi`, `references/kmp-mvvm.md` for `mvvm`). Both modes also follow `references/kmp-expert.md`.
|
|
32
33
|
- **Role schedule**: dispatch only role IDs listed in [SKILL.md](SKILL.md).
|
|
33
34
|
- **Mode discipline**:
|
|
34
35
|
- `module-implementation`: `ui` then `logic` — separate invocations
|
|
@@ -44,6 +45,9 @@
|
|
|
44
45
|
| Failure | Response |
|
|
45
46
|
|---|---|
|
|
46
47
|
| Unknown or invalid role ID | Reject; use role from `SKILL.md` registry |
|
|
48
|
+
| `design_mode` not identified at pre-flight | Default to `mvi`; record `source: default` in `run_manifest.json` |
|
|
49
|
+
| Architecture-producing dispatch missing `design_mode` | Reject; re-dispatch with `design_mode` + `architecture_reference_path` |
|
|
50
|
+
| Code produced against wrong architecture vs `design_mode` | `needs_rerun` owning role with correct reference |
|
|
47
51
|
| `ui` and `logic` combined | Reject invocation |
|
|
48
52
|
| `integrate` and `align` combined | Reject invocation |
|
|
49
53
|
| Verification restoration failed | Rerun `module-implementation` or `migration-prep`; no completion record |
|
|
@@ -147,7 +147,7 @@ output_root = <output_dir or ~/.a2c_agents/migration>/android-to-kmp-migrator
|
|
|
147
147
|
|
|
148
148
|
| Step | Gate | Required artifacts before next step |
|
|
149
149
|
|---|---|---|
|
|
150
|
-
| `MG0` | Run lock | `run_manifest.json
|
|
150
|
+
| `MG0` | Run lock | `run_manifest.json` (incl. `design_mode`), `upstream-index/upstream_analyst_index.json` |
|
|
151
151
|
| `MG1` | Workspace init | global `migration_workspace_state.*` |
|
|
152
152
|
| `MG2` | Migration index | `migration_module_inventory.*`, `modules_migration_index.json`, per-module `module_brief.json` |
|
|
153
153
|
| `MG3` | Target baseline | global `target-project-assistant/*` (`mode: global_baseline`) + `target_alignment_revision.*` |
|
|
@@ -241,6 +241,30 @@ output_root = <output_dir or ~/.a2c_agents/migration>/android-to-kmp-migrator
|
|
|
241
241
|
|
|
242
242
|
## Key Artifact Content Requirements
|
|
243
243
|
|
|
244
|
+
### `run_manifest.json` → `design_mode`
|
|
245
|
+
|
|
246
|
+
Records the presentation architecture pattern, identified from **user input** at pre-flight (Step 0a) and **frozen for the run**. Default is `mvi` when user input gives no clear signal.
|
|
247
|
+
|
|
248
|
+
```json
|
|
249
|
+
{
|
|
250
|
+
"design_mode": {
|
|
251
|
+
"value": "mvi",
|
|
252
|
+
"source": "default",
|
|
253
|
+
"signals": [],
|
|
254
|
+
"architecture_reference_path": "references/kmp-mvi-flowredux.md"
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
| Field | Meaning |
|
|
260
|
+
|---|---|
|
|
261
|
+
| `value` | `mvi` (default) \| `mvvm` |
|
|
262
|
+
| `source` | `user_input` when a signal was matched; `default` when none |
|
|
263
|
+
| `signals` | matched keywords/phrases from user input (empty when defaulted) |
|
|
264
|
+
| `architecture_reference_path` | `references/kmp-mvi-flowredux.md` for `mvi`, `references/kmp-mvvm.md` for `mvvm` |
|
|
265
|
+
|
|
266
|
+
The Leader MUST pass `design_mode.value` and `design_mode.architecture_reference_path` into every architecture-producing dispatch (`migration-planning-gate`, `migration-prep`, `module-implementation`, `module-node-review-fix`, `global-migration-phase`) and to `target-project-assistant` for target-pattern detection.
|
|
267
|
+
|
|
244
268
|
### `upstream_analyst_index.json`
|
|
245
269
|
|
|
246
270
|
```json
|
|
@@ -328,7 +352,7 @@ Human/agent-readable synthesis of align mode; includes `entry_point_alignment_re
|
|
|
328
352
|
|
|
329
353
|
## Leader Obligations
|
|
330
354
|
|
|
331
|
-
1. Verify analyst package `P6` before `MG0` completes.
|
|
355
|
+
1. Verify analyst package `P6` before `MG0` completes; identify `design_mode` from user input (default `mvi`) and write it to `run_manifest.json` at `MG0`, then pass `design_mode` + `architecture_reference_path` into every architecture-producing dispatch.
|
|
332
356
|
2. Dispatch `target-project-assistant` for all target-project questions; other roles MUST reference TPA artifacts instead of re-analyzing target ad hoc.
|
|
333
357
|
3. Ensure each module produces **target KMP edits** via `module-implementation` (and optional `migration-prep` / `module-node-review-fix` `fix`) before writing `module_completion_record.json`.
|
|
334
358
|
4. Write `module_completion_record.json` after each module passes `migration-verification`; include aggregated `target_changed_files[]` for the module.
|
|
@@ -353,3 +377,6 @@ Human/agent-readable synthesis of align mode; includes `entry_point_alignment_re
|
|
|
353
377
|
| Planning complete but `changed_files[]` empty when tasks require edits | `needs_rerun` → `module-implementation` or `migration-prep` |
|
|
354
378
|
| `changed_files` paths outside `kmp_target_project_path` | `blocked` — reject artifact; rerun owning role |
|
|
355
379
|
| `target_files_exist` failed | `needs_rerun` → owning edit role |
|
|
380
|
+
| `design_mode` missing from `run_manifest.json` at MG0 | `blocked`, `reason: missing` — Leader must identify (default `mvi`) before module dispatch |
|
|
381
|
+
| Architecture-producing dispatch missing `design_mode` / `architecture_reference_path` | Reject dispatch; re-dispatch with `design_mode` injected |
|
|
382
|
+
| Implementation/review uses the wrong architecture vs `design_mode` | `needs_rerun` → owning role with correct `architecture_reference_path` |
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kmp-expert
|
|
3
|
+
description: >
|
|
4
|
+
Foundational Kotlin Multiplatform / Compose Multiplatform knowledge for KMP/CMP
|
|
5
|
+
development. Use when working on any KMP/CMP task: project setup, source set
|
|
6
|
+
hierarchy, expect/actual, Gradle/version-catalog config, choosing the library
|
|
7
|
+
stack (Ktor, SQLDelight, Room, Koin, serialization), iOS interop (SKIE,
|
|
8
|
+
KMP-NativeCoroutines), Wasm/web targets, or debugging build/source-set issues.
|
|
9
|
+
Triggers on Kotlin Multiplatform, KMP, CMP, Compose Multiplatform, commonMain,
|
|
10
|
+
expect/actual, shared module, iosMain, klib, Kotlin/Native.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Kotlin Multiplatform / Compose Multiplatform — expert skill
|
|
14
|
+
|
|
15
|
+
This is the foundational knowledge layer for KMP/CMP work. For presentation
|
|
16
|
+
architecture, see the companion `kmp-mvvm` and `kmp-mvi-flowredux` skills — this
|
|
17
|
+
skill covers everything beneath them: structure, source sets, interop, and the stack.
|
|
18
|
+
|
|
19
|
+
References:
|
|
20
|
+
- https://kotlinlang.org/docs/multiplatform/multiplatform-discover-project.html
|
|
21
|
+
- https://kotlinlang.org/docs/multiplatform/multiplatform-hierarchy.html
|
|
22
|
+
- https://blog.jetbrains.com/kotlin/2026/05/new-kmp-default-structure/
|
|
23
|
+
|
|
24
|
+
## KMP vs CMP — know which one you mean
|
|
25
|
+
|
|
26
|
+
- **KMP (Kotlin Multiplatform)** — shares non-UI logic (networking, persistence,
|
|
27
|
+
business rules). You build the UI natively per platform (Compose on Android,
|
|
28
|
+
SwiftUI on iOS). Officially supported by Google for Android/iOS logic sharing.
|
|
29
|
+
- **CMP (Compose Multiplatform)** — a declarative UI framework layered on top of KMP
|
|
30
|
+
that lets you share the UI too. On iOS it renders via Skia (Skiko), not native widgets.
|
|
31
|
+
|
|
32
|
+
Decision rule: push as much as possible into `commonMain`. Use KMP-only when you need
|
|
33
|
+
the deepest native iOS fidelity; use CMP when one UI codebase outweighs that.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Compilation targets
|
|
38
|
+
|
|
39
|
+
| Target | Compiler | Output |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| Android / JVM backend | Kotlin/JVM | JVM bytecode |
|
|
42
|
+
| iOS / macOS / watchOS / tvOS / Linux / Windows | Kotlin/Native (LLVM) | Native binary / `.framework` |
|
|
43
|
+
| Web | Kotlin/Wasm (or legacy Kotlin/JS) | WebAssembly / JS |
|
|
44
|
+
|
|
45
|
+
Kotlin/Native produces standalone binaries with no VM, which is how KMP gets native
|
|
46
|
+
performance on iOS.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Project structure (JetBrains 2026 default)
|
|
51
|
+
|
|
52
|
+
The default structure changed in 2026 to give each module a single responsibility,
|
|
53
|
+
aligning with Android Gradle Plugin 9.0.
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
project-root/
|
|
57
|
+
├── shared/ ← KMP library: ALL shared code (the only multiplatform module)
|
|
58
|
+
│ └── src/
|
|
59
|
+
│ ├── commonMain/ ← 80–90% of code lives here
|
|
60
|
+
│ ├── androidMain/ ← Android actuals + JVM-only deps
|
|
61
|
+
│ ├── iosMain/ ← iOS actuals (intermediate; see hierarchy below)
|
|
62
|
+
│ ├── desktopMain/ ← JVM desktop actuals
|
|
63
|
+
│ ├── wasmJsMain/ ← web actuals
|
|
64
|
+
│ └── commonTest/
|
|
65
|
+
├── androidApp/ ← thin Android entry point, depends on :shared
|
|
66
|
+
├── iosApp/ ← Xcode project consuming the shared framework
|
|
67
|
+
├── desktopApp/ ← JVM desktop entry point
|
|
68
|
+
└── webApp/ ← Wasm/JS entry point
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The old single `composeApp` module that mixed library + app concerns is replaced by a
|
|
72
|
+
clean `shared` library plus separate `*App` modules. Generate new projects at
|
|
73
|
+
`kmp.jetbrains.com`. Reference samples: `KMP-App-Template`, `kotlinconf-app`, RSS Reader.
|
|
74
|
+
|
|
75
|
+
For large apps, modularize further: feature modules (`:feature-x`) each split into
|
|
76
|
+
`domain`/`data`/`presentation`, plus shared `:core-network`, `:core-db`, `:core-ui`.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Source set hierarchy — the mental model
|
|
81
|
+
|
|
82
|
+
Source sets form a tree. During compilation for a target, Kotlin combines **all** source
|
|
83
|
+
sets on the path from `commonMain` down to the platform leaf.
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
commonMain
|
|
87
|
+
/ \
|
|
88
|
+
appleMain jvmAndroidShared (you create if needed)
|
|
89
|
+
/ \ / \
|
|
90
|
+
iosMain macosMain androidMain desktopMain
|
|
91
|
+
/ \
|
|
92
|
+
iosArm64 iosSimulatorArm64
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Key facts:
|
|
96
|
+
- `commonMain` compiles to every target; code here may use only multiplatform APIs.
|
|
97
|
+
- **Intermediate source sets** (`iosMain`, `appleMain`, `nativeMain`) share code among a
|
|
98
|
+
subset of targets. Put `actual` declarations in the intermediate set (e.g. `iosMain`),
|
|
99
|
+
not in each leaf (`iosArm64Main`, `iosSimulatorArm64Main`).
|
|
100
|
+
- The **default hierarchy template** (modern Kotlin) auto-wires `nativeMain`, `appleMain`,
|
|
101
|
+
`iosMain`, etc. You rarely need manual `dependsOn` anymore.
|
|
102
|
+
- Kotlin does **not** auto-share a JVM+Android source set. If their deps overlap, create a
|
|
103
|
+
custom intermediate set and wire it with `dependsOn` (IDE intellisense may complain but
|
|
104
|
+
it compiles).
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## expect / actual — the platform contract
|
|
109
|
+
|
|
110
|
+
The cornerstone mechanism. Declare an `expect` in `commonMain`; provide an `actual` per
|
|
111
|
+
target (or per intermediate source set).
|
|
112
|
+
|
|
113
|
+
```kotlin
|
|
114
|
+
// commonMain
|
|
115
|
+
expect fun getPlatform(): Platform
|
|
116
|
+
interface Platform { val name: String }
|
|
117
|
+
|
|
118
|
+
// androidMain
|
|
119
|
+
actual fun getPlatform(): Platform = object : Platform {
|
|
120
|
+
override val name = "Android ${Build.VERSION.SDK_INT}"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// iosMain
|
|
124
|
+
actual fun getPlatform(): Platform = object : Platform {
|
|
125
|
+
override val name = UIDevice.currentDevice.systemName()
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Three forms:
|
|
130
|
+
1. `expect fun` / `actual fun` — functions (most common)
|
|
131
|
+
2. `expect class` / `actual class` — full classes
|
|
132
|
+
3. **Interface + expect factory** (recommended) — declare an `interface` in common, an
|
|
133
|
+
`expect fun buildX(): Interface` factory, and platform `actual` factories returning
|
|
134
|
+
platform impls. This keeps common code free of platform types and is easier to test.
|
|
135
|
+
|
|
136
|
+
Prefer the interface+factory form over `expect class`; it avoids the strictness of
|
|
137
|
+
matching every member signature across platforms.
|
|
138
|
+
|
|
139
|
+
When NOT to use expect/actual: if a library already provides a multiplatform API (Ktor,
|
|
140
|
+
SQLDelight, kotlinx-datetime), use it directly in `commonMain` — no expect/actual needed.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## The standard 2026 library stack
|
|
145
|
+
|
|
146
|
+
Verify multiplatform support at `klibs.io` before adding any dependency.
|
|
147
|
+
|
|
148
|
+
| Concern | Library | Notes |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| Networking | **Ktor client** | `commonMain` core + per-platform engine (OkHttp/Android, Darwin/iOS, CIO/desktop) |
|
|
151
|
+
| Serialization | **kotlinx.serialization** | apply the plugin to the module that defines `@Serializable` types |
|
|
152
|
+
| Persistence | **SQLDelight** (typed SQL) or **Room KMP** (Google, DAO-style) | SQLDelight for control; Room for Android-team familiarity |
|
|
153
|
+
| Key-value | **multiplatform-settings** or **DataStore** | small prefs |
|
|
154
|
+
| DI | **Koin** | no codegen → fast multiplatform compile; `koin-compose-viewmodel` for CMP |
|
|
155
|
+
| Dates/time | **kotlinx-datetime** | multiplatform `Instant`, `LocalDate`, time zones |
|
|
156
|
+
| Coroutines | **kotlinx-coroutines-core** | the concurrency backbone |
|
|
157
|
+
| Image loading | **Coil 3** | multiplatform |
|
|
158
|
+
| Navigation | Navigation-Compose (KMP), **Voyager**, or **Decompose** | Decompose pairs well with KMP lifecycle |
|
|
159
|
+
| Testing | **kotlinx-coroutines-test** + **Turbine** | flow testing |
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Gradle configuration (modern, terse)
|
|
164
|
+
|
|
165
|
+
```kotlin
|
|
166
|
+
// shared/build.gradle.kts
|
|
167
|
+
plugins {
|
|
168
|
+
kotlin("multiplatform")
|
|
169
|
+
kotlin("plugin.serialization")
|
|
170
|
+
id("com.android.library")
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
kotlin {
|
|
174
|
+
androidTarget()
|
|
175
|
+
iosX64(); iosArm64(); iosSimulatorArm64()
|
|
176
|
+
jvm("desktop")
|
|
177
|
+
wasmJs { browser() }
|
|
178
|
+
|
|
179
|
+
// Default hierarchy template auto-creates iosMain/appleMain/nativeMain.
|
|
180
|
+
sourceSets {
|
|
181
|
+
commonMain.dependencies {
|
|
182
|
+
implementation(libs.ktor.client.core)
|
|
183
|
+
implementation(libs.kotlinx.serialization.json)
|
|
184
|
+
implementation(libs.koin.core)
|
|
185
|
+
implementation(libs.kotlinx.coroutines.core)
|
|
186
|
+
}
|
|
187
|
+
androidMain.dependencies { implementation(libs.ktor.client.okhttp) }
|
|
188
|
+
iosMain.dependencies { implementation(libs.ktor.client.darwin) }
|
|
189
|
+
getByName("desktopMain").dependencies { implementation(libs.ktor.client.cio) }
|
|
190
|
+
commonTest.dependencies {
|
|
191
|
+
implementation(libs.turbine)
|
|
192
|
+
implementation(libs.kotlinx.coroutines.test)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Use a `gradle/libs.versions.toml` version catalog for all dependency coordinates —
|
|
199
|
+
it is the standard for keeping versions consistent across modules.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## iOS interop — the hard part
|
|
204
|
+
|
|
205
|
+
Kotlin/Native exports through an Objective-C bridge, which is lossy: sealed classes lose
|
|
206
|
+
exhaustiveness, `Int?` becomes a boxed `KotlinInt`, and `suspend` functions become
|
|
207
|
+
completion-handler callbacks. Mitigations:
|
|
208
|
+
|
|
209
|
+
| Tool | What it fixes |
|
|
210
|
+
|---|---|
|
|
211
|
+
| **SKIE** | Maps Kotlin `Flow` → Swift `AsyncSequence`, sealed classes → Swift enums with associated values, default args. Easy setup, less verbose. |
|
|
212
|
+
| **KMP-NativeCoroutines** | Maps `suspend`/`Flow` to Swift `async/await`, Combine, or RxSwift with proper cancellation. The more battle-tested option. |
|
|
213
|
+
| **KMP-ObservableViewModel** | Lets SwiftUI observe Kotlin ViewModels and handles the iOS lifecycle/store-owner boilerplate. |
|
|
214
|
+
| **Swift Export (emerging)** | Direct Kotlin→Swift modules (suspend→async/await, sealed→enums) without the ObjC layer. Still experimental — don't build production architecture on it yet. |
|
|
215
|
+
|
|
216
|
+
Practical rules:
|
|
217
|
+
- Reduce the exported surface: mark internal code `internal`/`private` and enable
|
|
218
|
+
`explicitApi()` so you don't export everything by default.
|
|
219
|
+
- Annotate the flows/suspend functions you expose with the chosen tool's annotation
|
|
220
|
+
(`@NativeCoroutines`, `@NativeCoroutinesState`).
|
|
221
|
+
- iOS engineers should never see `KotlinInt` or completion handlers — that's a sign your
|
|
222
|
+
interop layer is missing.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Common gotchas
|
|
227
|
+
|
|
228
|
+
| Symptom | Cause | Fix |
|
|
229
|
+
|---|---|---|
|
|
230
|
+
| `@Serializable` compiles but crashes at runtime | serialization plugin not on the module defining the type | Apply `kotlin("plugin.serialization")` to that module |
|
|
231
|
+
| IDE shows red but it compiles | IntelliSense lagging on intermediate source sets | Sync Gradle; the build is the source of truth |
|
|
232
|
+
| Can't share code across JVM + Android | Kotlin doesn't auto-create that intermediate set | Create a custom source set with `dependsOn` |
|
|
233
|
+
| iOS sees opaque classes, no exhaustive switch | Raw ObjC bridge without SKIE | Add SKIE or KMP-NativeCoroutines |
|
|
234
|
+
| Slow Kotlin/Native builds | Native compilation is inherently slower than JVM | Use `embedAndSign`/`SKIE` caching; iterate on Android/desktop, verify on iOS less often |
|
|
235
|
+
| `expect class` won't compile — member mismatch | strict signature matching across platforms | Switch to interface + `expect` factory function |
|
|
236
|
+
| Coroutine on iOS never cancels | exposed raw `suspend` without interop annotation | Annotate with `@NativeCoroutines` for proper cancellation |
|
|
237
|
+
| Android-only API leaked into commonMain | wrote `android.*` import in common code | Move it behind expect/actual or an interface |
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Build/run quick reference
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
./gradlew :shared:build # compile the shared library, all targets
|
|
245
|
+
./gradlew :androidApp:installDebug # build + install Android app
|
|
246
|
+
./gradlew :desktopApp:run # run desktop (JVM) app
|
|
247
|
+
./gradlew :shared:iosSimulatorArm64Test # run iOS tests on simulator
|
|
248
|
+
# iOS app itself is built/run from Xcode (iosApp project) consuming the framework
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## When advising on KMP/CMP work
|
|
254
|
+
|
|
255
|
+
1. Default to putting code in `commonMain`; only drop to platform source sets when an API
|
|
256
|
+
genuinely differs. Reach for expect/actual last, libraries first.
|
|
257
|
+
2. Check `klibs.io` for multiplatform support before suggesting any dependency.
|
|
258
|
+
3. For UI: ask whether they want shared UI (CMP) or native UI (KMP-only) before scaffolding.
|
|
259
|
+
4. For presentation logic, defer to the `kmp-mvvm` or `kmp-mvi-flowredux` skill.
|
|
260
|
+
5. Always consider the iOS interop cost of anything exposed across the Swift boundary.
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kmp-mvi-flowredux
|
|
3
|
+
description: >
|
|
4
|
+
KMP/CMP MVI architecture using FlowRedux state machines. Use when building or
|
|
5
|
+
reviewing Kotlin Multiplatform features that need MVI pattern: state machines,
|
|
6
|
+
sealed State/Action classes, unidirectional data flow, Compose Multiplatform UI
|
|
7
|
+
wiring, or when the user mentions FlowRedux, MVI, state machine, inState, onEnter,
|
|
8
|
+
dispatch, or sealed interface State.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# KMP MVI with FlowRedux — architecture skill
|
|
12
|
+
|
|
13
|
+
Reference: https://freeletics.github.io/FlowRedux/
|
|
14
|
+
|
|
15
|
+
## Core concepts
|
|
16
|
+
|
|
17
|
+
MVI (Model-View-Intent) enforces **unidirectional data flow**:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Action (Intent) ──► StateMachine ──► State ──► UI
|
|
21
|
+
▲ │
|
|
22
|
+
└────────────────────────────────────────┘
|
|
23
|
+
dispatch()
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
FlowRedux models this as an explicit **state machine** with a Coroutines DSL:
|
|
27
|
+
- `State` — sealed interface; each subtype is a discrete screen state
|
|
28
|
+
- `Action` — sealed interface; every user gesture or external event
|
|
29
|
+
- `FlowReduxStateMachineFactory` — holds the `spec { }` DSL and produces instances
|
|
30
|
+
- `FlowReduxStateMachine` — public API: `state: Flow<State>` + `suspend dispatch(action)`
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Architecture layers
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
:shared/commonMain
|
|
38
|
+
├── feature/
|
|
39
|
+
│ ├── model/
|
|
40
|
+
│ │ ├── FeatureState.kt ← sealed interface State + subtypes
|
|
41
|
+
│ │ └── FeatureAction.kt ← sealed interface Action + subtypes
|
|
42
|
+
│ ├── statemachine/
|
|
43
|
+
│ │ └── FeatureStateMachineFactory.kt ← FlowReduxStateMachineFactory
|
|
44
|
+
│ └── domain/
|
|
45
|
+
│ └── FeatureRepository.kt ← interface; expect/actual for platform impl
|
|
46
|
+
└── di/
|
|
47
|
+
└── FeatureModule.kt ← Koin module wiring factory + repo
|
|
48
|
+
|
|
49
|
+
:composeApp/commonMain
|
|
50
|
+
└── feature/
|
|
51
|
+
├── FeatureScreen.kt ← @Composable, uses produceStateMachine()
|
|
52
|
+
└── FeatureViewModel.kt ← optional: ViewModel wrapper for lifecycle
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Rule:** All state machine logic lives in `:shared/commonMain`. The UI layer
|
|
56
|
+
(`:composeApp`) only renders state and dispatches actions — zero business logic.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Coding guidance
|
|
61
|
+
|
|
62
|
+
### 1. Define State and Action as sealed interfaces
|
|
63
|
+
|
|
64
|
+
```kotlin
|
|
65
|
+
// shared/commonMain/.../model/ItemListState.kt
|
|
66
|
+
sealed interface ItemListState {
|
|
67
|
+
data object Loading : ItemListState
|
|
68
|
+
data class ShowContent(val items: List<Item>) : ItemListState
|
|
69
|
+
data class Error(val message: String, val countdown: Int) : ItemListState
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// shared/commonMain/.../model/ItemListAction.kt
|
|
73
|
+
sealed interface ItemListAction {
|
|
74
|
+
data object RetryLoading : ItemListAction
|
|
75
|
+
data class ToggleFavorite(val itemId: Int) : ItemListAction
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Rules:
|
|
80
|
+
- `State` subtypes are `data object` (no fields) or `data class` (immutable fields)
|
|
81
|
+
- Never put UI logic inside a `State` — it is plain data
|
|
82
|
+
- `Action` subtypes carry only the payload the handler needs — nothing else
|
|
83
|
+
|
|
84
|
+
### 2. Write the StateMachineFactory in commonMain
|
|
85
|
+
|
|
86
|
+
```kotlin
|
|
87
|
+
// shared/commonMain/.../statemachine/ItemListStateMachineFactory.kt
|
|
88
|
+
class ItemListStateMachineFactory(
|
|
89
|
+
private val repository: ItemRepository
|
|
90
|
+
) : FlowReduxStateMachineFactory<ItemListState, ItemListAction>() {
|
|
91
|
+
|
|
92
|
+
init {
|
|
93
|
+
initializeWith { Loading }
|
|
94
|
+
|
|
95
|
+
spec {
|
|
96
|
+
inState<Loading> {
|
|
97
|
+
onEnter { state ->
|
|
98
|
+
try {
|
|
99
|
+
val items = repository.loadItems()
|
|
100
|
+
state.override { ShowContent(items) }
|
|
101
|
+
} catch (t: Throwable) {
|
|
102
|
+
state.override { Error(t.message ?: "Unknown error", countdown = 3) }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
inState<Error> {
|
|
108
|
+
on<RetryLoading> { _, state ->
|
|
109
|
+
state.override { Loading }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
collectWhileInState(timerEverySecond()) { _, state ->
|
|
113
|
+
val next = state.snapshot.countdown - 1
|
|
114
|
+
if (next <= 0) state.override { Loading }
|
|
115
|
+
else state.mutate { copy(countdown = next) }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
inState<ShowContent> {
|
|
120
|
+
on<ToggleFavorite>(executionPolicy = ExecutionPolicy.Unordered) { action, state ->
|
|
121
|
+
// delegate to child state machine — see composing section
|
|
122
|
+
state.mutate { copy(items = items.map { item ->
|
|
123
|
+
if (item.id == action.itemId) item.copy(toggling = true) else item
|
|
124
|
+
}) }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Rules:
|
|
133
|
+
- One factory per feature/screen — not one global machine
|
|
134
|
+
- `initializeWith { }` always first in `init`
|
|
135
|
+
- Extract large handlers into `private suspend fun` for readability and unit-testability
|
|
136
|
+
- Never call `state.override` or `state.mutate` after the function returns — these are the only mutation points
|
|
137
|
+
|
|
138
|
+
### 3. DSL blocks reference
|
|
139
|
+
|
|
140
|
+
| Block | Triggers when | Typical use |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `onEnter { }` | State is entered | Load data, start timers |
|
|
143
|
+
| `on<Action> { }` | Action arrives while in this state | User interactions |
|
|
144
|
+
| `collectWhileInState(flow) { }` | Flow emits while in this state | Countdown, realtime updates |
|
|
145
|
+
| `onActionEffect<Action> { }` | Like `on<>` but does NOT transition state | Logging, analytics side-effects |
|
|
146
|
+
| `condition { }` | Wraps sub-blocks with a predicate | Feature flags, conditional handlers |
|
|
147
|
+
| `untilIdentityChanged { }` | Re-enters block when identity field changes | Pagination, refresh on id change |
|
|
148
|
+
|
|
149
|
+
### 4. ExecutionPolicy — choose deliberately
|
|
150
|
+
|
|
151
|
+
```kotlin
|
|
152
|
+
// Default: CancelPrevious — cancel in-flight handler when same action fires again
|
|
153
|
+
on<SearchQueryChanged> { action, state -> ... }
|
|
154
|
+
|
|
155
|
+
// Unordered — run in parallel, no order guarantee; use for independent async ops
|
|
156
|
+
on<ToggleFavorite>(executionPolicy = ExecutionPolicy.Unordered) { ... }
|
|
157
|
+
|
|
158
|
+
// Ordered — sequential; use when order matters and you must not cancel
|
|
159
|
+
on<PageLoad>(executionPolicy = ExecutionPolicy.Ordered) { ... }
|
|
160
|
+
|
|
161
|
+
// Throttled — ignore re-triggers within duration; use for debounce-like behaviour
|
|
162
|
+
on<ButtonClick>(executionPolicy = ExecutionPolicy.Throttled(500.milliseconds)) { ... }
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### 5. Compose Multiplatform UI wiring
|
|
166
|
+
|
|
167
|
+
```kotlin
|
|
168
|
+
// composeApp/commonMain/.../FeatureScreen.kt
|
|
169
|
+
@Composable
|
|
170
|
+
fun ItemListScreen(factory: ItemListStateMachineFactory) {
|
|
171
|
+
val stateMachine = factory.produceStateMachine() // FlowRedux compose extension
|
|
172
|
+
val state by stateMachine.state.collectAsState() // or use stateMachine.state.value
|
|
173
|
+
|
|
174
|
+
when (val s = state) {
|
|
175
|
+
is Loading -> LoadingIndicator()
|
|
176
|
+
is ShowContent -> ContentList(s.items, onToggle = {
|
|
177
|
+
stateMachine.dispatch(ToggleFavorite(it)) // dispatch is non-suspending in compose ext
|
|
178
|
+
})
|
|
179
|
+
is Error -> ErrorView(s.message, s.countdown, onRetry = {
|
|
180
|
+
stateMachine.dispatch(RetryLoading)
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
For Android ViewModel lifecycle:
|
|
187
|
+
|
|
188
|
+
```kotlin
|
|
189
|
+
class ItemListViewModel @Inject constructor(
|
|
190
|
+
private val factory: ItemListStateMachineFactory
|
|
191
|
+
) : ViewModel() {
|
|
192
|
+
private val stateMachine = factory.shareIn(viewModelScope)
|
|
193
|
+
val state: StateFlow<ItemListState> = stateMachine.state
|
|
194
|
+
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Loading)
|
|
195
|
+
|
|
196
|
+
fun dispatch(action: ItemListAction) {
|
|
197
|
+
viewModelScope.launch { stateMachine.dispatch(action) }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### 6. Composing state machines (hierarchical)
|
|
203
|
+
|
|
204
|
+
Delegate a sub-problem to a child state machine rather than bloating the parent spec:
|
|
205
|
+
|
|
206
|
+
```kotlin
|
|
207
|
+
inState<ShowContent> {
|
|
208
|
+
onActionStartStateMachine(
|
|
209
|
+
stateMachineFactoryBuilder = { action: ToggleFavorite ->
|
|
210
|
+
FavoriteStatusStateMachineFactory(
|
|
211
|
+
itemId = action.itemId,
|
|
212
|
+
httpClient = httpClient
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
) { favoriteStatus: FavoriteStatus ->
|
|
216
|
+
// merge child state into parent state
|
|
217
|
+
mutate { copy(items = items.updateFavoriteStatus(favoriteStatus)) }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Use `onEnterStartStateMachine()` when you want a child machine to start on state entry
|
|
223
|
+
rather than on an explicit action.
|
|
224
|
+
|
|
225
|
+
### 7. Effects (side-effects without state transition)
|
|
226
|
+
|
|
227
|
+
```kotlin
|
|
228
|
+
inState<ShowContent> {
|
|
229
|
+
onActionEffect<ToggleFavorite> { action, stateSnapshot ->
|
|
230
|
+
analytics.track("toggle_favorite", mapOf("id" to action.itemId))
|
|
231
|
+
// cannot mutate state from an effect block
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Use `*Effect` variants whenever you need to react to an action or event but must not
|
|
237
|
+
change state (navigation events, analytics, logging).
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Project structure checklist
|
|
242
|
+
|
|
243
|
+
```
|
|
244
|
+
:shared
|
|
245
|
+
commonMain
|
|
246
|
+
└── feature/itemlist/
|
|
247
|
+
├── model/
|
|
248
|
+
│ ├── ItemListState.kt ✓ sealed interface, data classes, immutable
|
|
249
|
+
│ └── ItemListAction.kt ✓ sealed interface
|
|
250
|
+
├── statemachine/
|
|
251
|
+
│ ├── ItemListStateMachineFactory.kt ✓ extends FlowReduxStateMachineFactory
|
|
252
|
+
│ └── FavoriteStatusStateMachineFactory.kt ✓ child machine
|
|
253
|
+
├── domain/
|
|
254
|
+
│ └── ItemRepository.kt ✓ interface only
|
|
255
|
+
└── data/
|
|
256
|
+
└── ItemRepositoryImpl.kt ✓ actual implementation (or expect/actual)
|
|
257
|
+
|
|
258
|
+
:composeApp
|
|
259
|
+
commonMain
|
|
260
|
+
└── feature/itemlist/
|
|
261
|
+
├── ItemListScreen.kt ✓ @Composable, pure render + dispatch
|
|
262
|
+
└── ItemListViewModel.kt ✓ only if AndroidX lifecycle needed
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## Testing guidance
|
|
268
|
+
|
|
269
|
+
Prefer **functional integration tests** with Turbine over unit tests per handler.
|
|
270
|
+
|
|
271
|
+
```kotlin
|
|
272
|
+
// Use runTest + Turbine for the full state machine
|
|
273
|
+
@Test
|
|
274
|
+
fun `loading transitions to ShowContent on success`() = runTest {
|
|
275
|
+
val factory = ItemListStateMachineFactory(FakeItemRepository(items = sampleItems))
|
|
276
|
+
val sm = factory.shareIn(backgroundScope)
|
|
277
|
+
|
|
278
|
+
sm.state.test {
|
|
279
|
+
assertEquals(Loading, awaitItem())
|
|
280
|
+
assertEquals(ShowContent(sampleItems), awaitItem())
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Override initial state to test mid-flow without replaying all transitions
|
|
285
|
+
@Test
|
|
286
|
+
fun `retry from Error transitions to Loading`() = runTest {
|
|
287
|
+
val factory = ItemListStateMachineFactory(FakeItemRepository())
|
|
288
|
+
factory.initializeWith { Error("oops", countdown = 3) }
|
|
289
|
+
val sm = factory.shareIn(backgroundScope)
|
|
290
|
+
|
|
291
|
+
sm.state.test {
|
|
292
|
+
assertEquals(Error("oops", 3), awaitItem())
|
|
293
|
+
sm.dispatch(RetryLoading)
|
|
294
|
+
assertEquals(Loading, awaitItem())
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
For **unit testing handlers**, extract them to `private suspend fun` and test with
|
|
300
|
+
`ChangeableState` + `changedState.reduce(snapshot)` directly.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Gradle dependencies
|
|
305
|
+
|
|
306
|
+
```kotlin
|
|
307
|
+
// shared/build.gradle.kts
|
|
308
|
+
kotlin {
|
|
309
|
+
sourceSets {
|
|
310
|
+
commonMain.dependencies {
|
|
311
|
+
// FlowRedux core (KMP)
|
|
312
|
+
implementation("com.freeletics.flowredux:flowredux:2.0.1")
|
|
313
|
+
// Coroutines
|
|
314
|
+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// composeApp/build.gradle.kts
|
|
320
|
+
commonMain.dependencies {
|
|
321
|
+
// FlowRedux Compose extensions (produceStateMachine, dispatch)
|
|
322
|
+
implementation("com.freeletics.flowredux:compose:2.0.1")
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Testing
|
|
326
|
+
commonTest.dependencies {
|
|
327
|
+
implementation("app.cash.turbine:turbine:1.1.0")
|
|
328
|
+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## Anti-patterns to avoid
|
|
335
|
+
|
|
336
|
+
| Anti-pattern | Fix |
|
|
337
|
+
|---|---|
|
|
338
|
+
| Business logic inside `@Composable` | Move to `inState { }` handlers |
|
|
339
|
+
| Mutable state inside `State` data class | All fields must be `val`; use `state.mutate { copy(...) }` |
|
|
340
|
+
| One giant state machine for the whole app | One factory per screen/feature |
|
|
341
|
+
| `state.override` called after a `return` | Override is the last expression in the lambda |
|
|
342
|
+
| Catching all `Throwable` silently | Log or encode the error into an `Error` state subtype |
|
|
343
|
+
| Skipping `ExecutionPolicy` on concurrent actions | Explicitly choose `Unordered` or `Ordered` where needed |
|
|
344
|
+
| Navigation logic inside state | Use `onActionEffect` + a navigation callback; state stays pure data |
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kmp-mvvm
|
|
3
|
+
description: >
|
|
4
|
+
KMP/CMP MVVM architecture with shared ViewModels. Use when building or reviewing
|
|
5
|
+
Kotlin Multiplatform features that follow the MVVM pattern: ViewModel + StateFlow,
|
|
6
|
+
UI state classes, state hoisting, collectAsStateWithLifecycle, Koin viewModel
|
|
7
|
+
injection, or bridging shared ViewModels to SwiftUI on iOS. Triggers on MVVM,
|
|
8
|
+
ViewModel, StateFlow, uiState, viewModelScope, KMP-ObservableViewModel, SKIE.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# KMP MVVM with shared ViewModels — architecture skill
|
|
12
|
+
|
|
13
|
+
References:
|
|
14
|
+
- https://kotlinlang.org/docs/multiplatform/compose-viewmodel.html
|
|
15
|
+
- https://touchlab.co/kmp-view-models
|
|
16
|
+
|
|
17
|
+
## Core concepts
|
|
18
|
+
|
|
19
|
+
MVVM separates concerns into three groups:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
observe (StateFlow) user events (fun calls)
|
|
23
|
+
View ◄───────────────────────── ViewModel ◄──────────────────── View
|
|
24
|
+
│ │
|
|
25
|
+
│ ▼
|
|
26
|
+
│ Model (UseCase / Repository)
|
|
27
|
+
└─ renders UiState │
|
|
28
|
+
▼
|
|
29
|
+
data / network / cache
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **Model** — domain + data layer (UseCases, Repositories) in `commonMain`
|
|
33
|
+
- **ViewModel** — holds and exposes immutable `UiState` as `StateFlow`, handles events
|
|
34
|
+
- **View** — Compose Multiplatform (shared) or native (SwiftUI/Compose), renders state only
|
|
35
|
+
|
|
36
|
+
MVVM vs MVI: MVVM exposes a single immutable `UiState` and *public methods* for events,
|
|
37
|
+
rather than dispatching `Action` objects through a reducer. Use MVVM when the team is
|
|
38
|
+
Android/Compose-native and the per-screen state is simple; reach for MVI/FlowRedux when
|
|
39
|
+
state transitions are complex enough to warrant an explicit state machine.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Architecture layers
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
:shared/commonMain
|
|
47
|
+
├── feature/order/
|
|
48
|
+
│ ├── presentation/
|
|
49
|
+
│ │ ├── OrderUiState.kt ← single immutable data class (the "Model" the View sees)
|
|
50
|
+
│ │ └── OrderViewModel.kt ← extends ViewModel, exposes StateFlow<OrderUiState>
|
|
51
|
+
│ ├── domain/
|
|
52
|
+
│ │ ├── OrderRepository.kt ← interface
|
|
53
|
+
│ │ └── PlaceOrderUseCase.kt ← business logic, no Android deps
|
|
54
|
+
│ └── data/
|
|
55
|
+
│ └── OrderRepositoryImpl.kt ← actual impl (Ktor/SQLDelight)
|
|
56
|
+
└── di/
|
|
57
|
+
└── OrderModule.kt ← Koin module: viewModelOf(::OrderViewModel)
|
|
58
|
+
|
|
59
|
+
:composeApp/commonMain
|
|
60
|
+
└── feature/order/
|
|
61
|
+
└── OrderScreen.kt ← @Composable, koinViewModel() + collectAsStateWithLifecycle()
|
|
62
|
+
|
|
63
|
+
iosApp (Swift) ← observes ViewModel via KMP-ObservableViewModel
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Rule:** ViewModel, UiState, and all logic live in `:shared/commonMain`. The View only
|
|
67
|
+
collects state and calls ViewModel methods.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Choosing the ViewModel base class
|
|
72
|
+
|
|
73
|
+
There are three viable approaches. Pick one per project and stay consistent.
|
|
74
|
+
|
|
75
|
+
| Approach | commonMain dependency | iOS story | When to use |
|
|
76
|
+
|---|---|---|---|
|
|
77
|
+
| **AndroidX `lifecycle-viewmodel` (multiplatform)** | `androidx.lifecycle:lifecycle-viewmodel:2.8+` | Manual lifecycle on iOS; brings `viewModelScope` baggage | CMP-first apps where iOS UI is also Compose |
|
|
78
|
+
| **KMP-ObservableViewModel** (rickclephas) | `com.rickclephas.kmp:kmp-observableviewmodel-core` | First-class: SwiftUI observes directly, handles store-owner boilerplate | **Recommended** when iOS uses SwiftUI; this is the official Kotlin-docs recommendation |
|
|
79
|
+
| **Pure Kotlin ViewModel** (your own `interface`/base) | none | Explicit Swift observer wrapper you write | Maximum control, zero androidx in commonMain |
|
|
80
|
+
|
|
81
|
+
The official Kotlin documentation recommends **KMP-ObservableViewModel** for SwiftUI
|
|
82
|
+
because there is no built-in `ViewModelStoreOwner` on iOS — the library ties the
|
|
83
|
+
ViewModel lifecycle to the SwiftUI view automatically.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Coding guidance
|
|
88
|
+
|
|
89
|
+
### 1. Model UiState as a single immutable data class
|
|
90
|
+
|
|
91
|
+
```kotlin
|
|
92
|
+
// shared/commonMain/.../presentation/OrderUiState.kt
|
|
93
|
+
data class OrderUiState(
|
|
94
|
+
val quantity: Int = 1,
|
|
95
|
+
val items: List<OrderItem> = emptyList(),
|
|
96
|
+
val isLoading: Boolean = false,
|
|
97
|
+
val errorMessage: String? = null,
|
|
98
|
+
)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Rules:
|
|
102
|
+
- One `UiState` per screen — never expose multiple loose `StateFlow`s for one screen
|
|
103
|
+
- All fields `val`, with sensible defaults
|
|
104
|
+
- Represent loading/error as fields (`isLoading`, `errorMessage`), or use a sealed
|
|
105
|
+
`UiState` if states are mutually exclusive enough to warrant it
|
|
106
|
+
- Keep it free of framework types (no `Composable`, no `Color`, no `Painter`)
|
|
107
|
+
|
|
108
|
+
### 2. ViewModel exposes StateFlow, never MutableStateFlow
|
|
109
|
+
|
|
110
|
+
```kotlin
|
|
111
|
+
// shared/commonMain/.../presentation/OrderViewModel.kt
|
|
112
|
+
import com.rickclephas.kmp.observableviewmodel.ViewModel
|
|
113
|
+
import com.rickclephas.kmp.observableviewmodel.MutableStateFlow
|
|
114
|
+
import com.rickclephas.kmp.observableviewmodel.stateIn
|
|
115
|
+
import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
|
|
116
|
+
|
|
117
|
+
class OrderViewModel(
|
|
118
|
+
private val placeOrder: PlaceOrderUseCase,
|
|
119
|
+
private val repository: OrderRepository,
|
|
120
|
+
) : ViewModel() {
|
|
121
|
+
|
|
122
|
+
private val _uiState = MutableStateFlow(viewModelScope, OrderUiState())
|
|
123
|
+
|
|
124
|
+
@NativeCoroutinesState // exposes the flow cleanly to Swift
|
|
125
|
+
val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()
|
|
126
|
+
|
|
127
|
+
init { loadItems() }
|
|
128
|
+
|
|
129
|
+
fun setQuantity(n: Int) {
|
|
130
|
+
_uiState.update { it.copy(quantity = n) }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
fun submit() {
|
|
134
|
+
viewModelScope.coroutineScope.launch {
|
|
135
|
+
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
|
136
|
+
runCatching { placeOrder(_uiState.value.quantity) }
|
|
137
|
+
.onSuccess { _uiState.update { s -> s.copy(isLoading = false) } }
|
|
138
|
+
.onFailure { t -> _uiState.update { s -> s.copy(isLoading = false, errorMessage = t.message) } }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private fun loadItems() { /* launch in viewModelScope, update _uiState */ }
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Rules:
|
|
147
|
+
- `_uiState` is private `MutableStateFlow`; expose read-only `StateFlow` via `asStateFlow()`
|
|
148
|
+
- Always update via `update { copy(...) }` — atomic, avoids race conditions
|
|
149
|
+
- Launch coroutines in `viewModelScope` so they cancel when the ViewModel clears
|
|
150
|
+
- Annotate the public flow with `@NativeCoroutinesState` for clean Swift interop
|
|
151
|
+
- Never expose `suspend` functions to the View; the View calls plain `fun`, the VM launches
|
|
152
|
+
|
|
153
|
+
### 3. Compose Multiplatform View — state hoisting + lifecycle-aware collection
|
|
154
|
+
|
|
155
|
+
```kotlin
|
|
156
|
+
// composeApp/commonMain/.../OrderScreen.kt
|
|
157
|
+
@Composable
|
|
158
|
+
fun OrderScreen(viewModel: OrderViewModel = koinViewModel()) {
|
|
159
|
+
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
160
|
+
|
|
161
|
+
OrderContent(
|
|
162
|
+
state = uiState,
|
|
163
|
+
onQuantityChange = viewModel::setQuantity,
|
|
164
|
+
onSubmit = viewModel::submit,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Stateless, hoisted — fully previewable and testable
|
|
169
|
+
@Composable
|
|
170
|
+
private fun OrderContent(
|
|
171
|
+
state: OrderUiState,
|
|
172
|
+
onQuantityChange: (Int) -> Unit,
|
|
173
|
+
onSubmit: () -> Unit,
|
|
174
|
+
) {
|
|
175
|
+
Column {
|
|
176
|
+
if (state.isLoading) LinearProgressIndicator()
|
|
177
|
+
QuantityStepper(state.quantity, onQuantityChange)
|
|
178
|
+
state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
|
179
|
+
Button(onClick = onSubmit, enabled = !state.isLoading) { Text("Place order") }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Rules:
|
|
185
|
+
- Split into a stateful screen (`koinViewModel()` + collect) and a stateless content
|
|
186
|
+
composable that takes `state` + lambdas — this is **state hoisting**
|
|
187
|
+
- Use `collectAsStateWithLifecycle()` (not bare `collectAsState()`) so collection pauses
|
|
188
|
+
when the UI is not visible
|
|
189
|
+
- Pass method references (`viewModel::setQuantity`), not the whole ViewModel, into content
|
|
190
|
+
- The stateless content composable has zero ViewModel dependency → trivially previewable
|
|
191
|
+
|
|
192
|
+
### 4. Dependency injection with Koin
|
|
193
|
+
|
|
194
|
+
```kotlin
|
|
195
|
+
// shared/commonMain/.../di/OrderModule.kt
|
|
196
|
+
val orderModule = module {
|
|
197
|
+
singleOf(::OrderRepositoryImpl) bind OrderRepository::class
|
|
198
|
+
factoryOf(::PlaceOrderUseCase)
|
|
199
|
+
viewModelOf(::OrderViewModel) // koin-compose-viewmodel
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Compose retrieves it with `koinViewModel()` (from `koin-compose-viewmodel`), which
|
|
204
|
+
scopes the ViewModel to the navigation entry / composition correctly on all platforms.
|
|
205
|
+
|
|
206
|
+
### 5. iOS bridge (SwiftUI)
|
|
207
|
+
|
|
208
|
+
With KMP-ObservableViewModel + `@NativeCoroutinesState`, SwiftUI observes directly:
|
|
209
|
+
|
|
210
|
+
```swift
|
|
211
|
+
struct OrderView: View {
|
|
212
|
+
@StateViewModel var viewModel = OrderViewModel(placeOrder: ..., repository: ...)
|
|
213
|
+
|
|
214
|
+
var body: some View {
|
|
215
|
+
VStack {
|
|
216
|
+
if viewModel.uiState.isLoading { ProgressView() }
|
|
217
|
+
Stepper("Qty: \(viewModel.uiState.quantity)",
|
|
218
|
+
value: Binding(get: { Int(viewModel.uiState.quantity) },
|
|
219
|
+
set: { viewModel.setQuantity(n: Int32($0)) }))
|
|
220
|
+
Button("Place order") { viewModel.submit() }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
If you instead use AndroidX ViewModel + SKIE, SKIE maps `StateFlow` to a Swift
|
|
227
|
+
`AsyncSequence`/`@Observable` and you bridge it with an observer wrapper.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Project structure checklist
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
:shared
|
|
235
|
+
commonMain
|
|
236
|
+
└── feature/order/
|
|
237
|
+
├── presentation/
|
|
238
|
+
│ ├── OrderUiState.kt ✓ single immutable data class
|
|
239
|
+
│ └── OrderViewModel.kt ✓ private Mutable, public StateFlow
|
|
240
|
+
├── domain/
|
|
241
|
+
│ ├── OrderRepository.kt ✓ interface
|
|
242
|
+
│ └── PlaceOrderUseCase.kt ✓ pure logic, no androidx
|
|
243
|
+
├── data/
|
|
244
|
+
│ └── OrderRepositoryImpl.kt ✓ Ktor / SQLDelight
|
|
245
|
+
└── di/OrderModule.kt ✓ viewModelOf(::OrderViewModel)
|
|
246
|
+
|
|
247
|
+
:composeApp
|
|
248
|
+
commonMain
|
|
249
|
+
└── feature/order/OrderScreen.kt ✓ stateful screen + stateless content
|
|
250
|
+
|
|
251
|
+
iosApp ✓ @StateViewModel, observes uiState
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Testing guidance
|
|
257
|
+
|
|
258
|
+
The ViewModel is a plain class — test it with `runTest` + Turbine, injecting fakes.
|
|
259
|
+
|
|
260
|
+
```kotlin
|
|
261
|
+
@Test
|
|
262
|
+
fun `submit sets loading then clears on success`() = runTest {
|
|
263
|
+
val vm = OrderViewModel(
|
|
264
|
+
placeOrder = FakePlaceOrderUseCase(succeeds = true),
|
|
265
|
+
repository = FakeOrderRepository(),
|
|
266
|
+
)
|
|
267
|
+
vm.uiState.test {
|
|
268
|
+
assertEquals(OrderUiState(), awaitItem()) // initial
|
|
269
|
+
vm.setQuantity(3)
|
|
270
|
+
assertEquals(3, awaitItem().quantity)
|
|
271
|
+
vm.submit()
|
|
272
|
+
assertTrue(awaitItem().isLoading) // loading on
|
|
273
|
+
assertFalse(awaitItem().isLoading) // cleared after success
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
The stateless content composable is tested separately with Compose UI tests / screenshot
|
|
279
|
+
tests — it takes a `UiState` directly, so no ViewModel is needed.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Gradle dependencies
|
|
284
|
+
|
|
285
|
+
```kotlin
|
|
286
|
+
// shared/build.gradle.kts
|
|
287
|
+
kotlin {
|
|
288
|
+
sourceSets {
|
|
289
|
+
commonMain.dependencies {
|
|
290
|
+
// Recommended iOS-friendly ViewModel (official Kotlin docs)
|
|
291
|
+
implementation("com.rickclephas.kmp:kmp-observableviewmodel-core:1.0.0-BETA-13")
|
|
292
|
+
// OR the AndroidX multiplatform ViewModel
|
|
293
|
+
// implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.8.4")
|
|
294
|
+
|
|
295
|
+
implementation("io.insert-koin:koin-core:4.0.0")
|
|
296
|
+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
|
|
297
|
+
}
|
|
298
|
+
commonTest.dependencies {
|
|
299
|
+
implementation("app.cash.turbine:turbine:1.1.0")
|
|
300
|
+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// composeApp/build.gradle.kts
|
|
306
|
+
commonMain.dependencies {
|
|
307
|
+
implementation("io.insert-koin:koin-compose-viewmodel:4.0.0") // koinViewModel()
|
|
308
|
+
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
For SwiftUI interop, add the KMP-ObservableViewModel Swift package, or KMP-NativeCoroutines / SKIE.
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Anti-patterns to avoid
|
|
317
|
+
|
|
318
|
+
| Anti-pattern | Fix |
|
|
319
|
+
|---|---|
|
|
320
|
+
| Exposing `MutableStateFlow` publicly | Expose read-only `StateFlow` via `asStateFlow()` |
|
|
321
|
+
| Multiple loose `StateFlow`s for one screen | Combine into one `UiState` data class |
|
|
322
|
+
| Business logic inside the `@Composable` | Move to the ViewModel; the View only renders + calls methods |
|
|
323
|
+
| Passing the whole ViewModel into child composables | Hoist state: pass `UiState` + lambdas |
|
|
324
|
+
| `collectAsState()` instead of `collectAsStateWithLifecycle()` | Use the lifecycle-aware variant to pause off-screen collection |
|
|
325
|
+
| `suspend` functions exposed to the View | Keep them internal; the VM launches in `viewModelScope` |
|
|
326
|
+
| Framework types (`Color`, `Painter`) inside `UiState` | Keep `UiState` platform-agnostic data only |
|
|
327
|
+
| AndroidX ViewModel in commonMain when targeting SwiftUI | Prefer KMP-ObservableViewModel or a pure-Kotlin base |
|
|
328
|
+
| Mutating state with `.value = .value.copy()` under concurrency | Use `update { copy(...) }` for atomic updates |
|
|
@@ -49,7 +49,7 @@ You are the `global-migration-phase` node subagent. Your job is **target KMP pro
|
|
|
49
49
|
- Integrate mode: no edits outside `kmp_target_project_path` or outside approved integration glue paths from TPA `integration_constraints`.
|
|
50
50
|
|
|
51
51
|
**Mandatory**:
|
|
52
|
-
- Integrate: validate `kmp_target_project_path`, package `M4`, all module representations, analyst cross-module globals, and `target_alignment_revision.json` before editing.
|
|
52
|
+
- Integrate: validate `kmp_target_project_path`, `design_mode` + `architecture_reference_path`, package `M4`, all module representations, analyst cross-module globals, and `target_alignment_revision.json` before editing. Cross-module glue, DI, and entry wiring MUST follow the run's `design_mode` (default `mvi`: state-machine wiring per `references/kmp-mvi-flowredux.md`; `mvvm`: `ViewModel`/Koin wiring per `references/kmp-mvvm.md`) and `references/kmp-expert.md` base KMP conventions — shared glue/nav/DI in `commonMain`, platform entry wrappers in `androidMain`/`iosMain`, `expect`/`actual` for platform launch hooks.
|
|
53
53
|
- Integrate: `output_dir = <global_dir>/node-results/global-migration-phase/integrate`
|
|
54
54
|
- Align: primary output under `<global_dir>/node-results/global-migration-phase/align`; alignment report under `report_dir`
|
|
55
55
|
- Include `mode` and `kmp_target_project_path` in JSON return payload.
|
|
@@ -190,6 +190,11 @@ Read-only verification after integrate. For each Legacy Android entry in analyst
|
|
|
190
190
|
```text
|
|
191
191
|
ROLE: global-migration-phase node in android-to-kmp-migrator. Modes: integrate | align. NEVER combine.
|
|
192
192
|
|
|
193
|
+
DESIGN MODE: glue/DI/entry wiring follows design_mode (default mvi → references/kmp-mvi-flowredux.md
|
|
194
|
+
state-machine wiring; mvvm → references/kmp-mvvm.md ViewModel/Koin wiring) and references/kmp-expert.md
|
|
195
|
+
base KMP conventions (shared glue/nav/DI in commonMain, platform entry wrappers + expect/actual launch
|
|
196
|
+
hooks in androidMain/iosMain). Do NOT mix patterns.
|
|
197
|
+
|
|
193
198
|
INTEGRATE — EDIT THE TARGET KMP PROJECT:
|
|
194
199
|
- Wire cross-module UI transitions, control logic handoffs, and data calls inside kmp_target_project_path.
|
|
195
200
|
- Wire entry points: Android launcher/Application/root nav/deep links → KMP app shell (entry_point_wiring[]).
|
|
@@ -208,7 +213,7 @@ CONTROL:
|
|
|
208
213
|
analyst cross-module globals, target_alignment_revision before editing.
|
|
209
214
|
- Align: consume global_system_integration output; inspect target files without modifying them.
|
|
210
215
|
|
|
211
|
-
INPUTS: mode, kmp_target_project_path, analyst cross_module_architecture_path,
|
|
216
|
+
INPUTS: mode, design_mode, architecture_reference_path, kmp_target_project_path, analyst cross_module_architecture_path,
|
|
212
217
|
cross_module_data_logic_path, module_migration_representation paths,
|
|
213
218
|
presentation_resource entry_points paths (per module + launcher module),
|
|
214
219
|
target_alignment_revision_path (entry_point_anchors[]), global_system_integration path (align mode),
|
|
@@ -9,7 +9,7 @@ You are the `migration-planning-gate` node subagent. You merge **migration analy
|
|
|
9
9
|
## Success Criteria
|
|
10
10
|
|
|
11
11
|
- `migration_planning_gate.json` and `migration_planning_gate.md` written under `output_dir`.
|
|
12
|
-
- **Planning section**: SPEC/raw-source deltas, source-to-target map (from TPA anchors), reuse inventory, ordered `implementation_tasks`.
|
|
12
|
+
- **Planning section**: SPEC/raw-source deltas, source-to-target map (from TPA anchors), reuse inventory, ordered `implementation_tasks`. The source-to-target map and tasks MUST follow the run's `design_mode` (default `mvi`) layout from `architecture_reference_path` — `mvi` (`references/kmp-mvi-flowredux.md`): `model/` (sealed `State`/`Action`), `statemachine/` (`FlowReduxStateMachineFactory`), `domain/`; `mvvm` (`references/kmp-mvvm.md`): `presentation/` (`ViewModel` + `UiState`), `domain/`, `data/`. Both modes target a KMP project per `references/kmp-expert.md` base conventions — map source to KMP source sets (`commonMain` first, `androidMain`/`iosMain` only for platform actuals) and the 2026 `shared` + `*App` module layout.
|
|
13
13
|
- **Dependency/platform section**: capability map, minimal-change dependency decisions, platform boundaries, `ready_for_implementation` or `blocked`.
|
|
14
14
|
- No feature UI/logic implementation; build-config changes only when gate justifies them.
|
|
15
15
|
|
|
@@ -64,9 +64,13 @@ See [output-contract.md](../output-contract.md). Artifact basename: `migration_p
|
|
|
64
64
|
ROLE: migration-planning-gate node. Merge planning + dependency/platform gate in ONE invocation.
|
|
65
65
|
|
|
66
66
|
PLANNING: SPEC deltas, source-to-target map from TPA anchors, ordered tasks. No target re-survey.
|
|
67
|
+
Layout follows design_mode (default mvi): mvi → model/statemachine/domain (references/kmp-mvi-flowredux.md);
|
|
68
|
+
mvvm → presentation(ViewModel+UiState)/domain/data (references/kmp-mvvm.md).
|
|
69
|
+
Both target a KMP project per references/kmp-expert.md base conventions: prefer commonMain, drop to
|
|
70
|
+
androidMain/iosMain only for platform actuals, follow the shared + *App module layout.
|
|
67
71
|
GATE: capability map, minimal-change deps, platform boundaries. ready_for_implementation or blocked.
|
|
68
72
|
|
|
69
|
-
INPUTS: migration_module_id, module_scope, module_brief_path, target_module_anchors_path,
|
|
73
|
+
INPUTS: design_mode, architecture_reference_path, migration_module_id, module_scope, module_brief_path, target_module_anchors_path,
|
|
70
74
|
target_alignment_revision_path, upstream module_representation, SPEC paths, target path,
|
|
71
75
|
allowed_files, allowed_source_sets, output_dir.
|
|
72
76
|
|
|
@@ -10,7 +10,7 @@ You are the `migration-prep` node subagent. You merge **presentation integration
|
|
|
10
10
|
|
|
11
11
|
- `migration_prep.json` and `migration_prep.md` written under `output_dir`.
|
|
12
12
|
- **Presentation section**: token mappings, resource mapping, route mapping, UI handoff, presentation gaps.
|
|
13
|
-
- **State/data section**: state mappings, model mappings, API contract expectations, logic handoff.
|
|
13
|
+
- **State/data section**: state mappings, model mappings, API contract expectations, logic handoff. State holder expectations MUST follow the run's `design_mode` (default `mvi`): `mvi` → sealed `State`/`Action` + state-machine handoff (`references/kmp-mvi-flowredux.md`); `mvvm` → immutable `UiState` + `ViewModel` event-method handoff (`references/kmp-mvvm.md`). All scaffold and contracts target a KMP project per `references/kmp-expert.md` base conventions — place shared tokens/resources/models/routes in `commonMain`, reserve `androidMain`/`iosMain` for platform actuals, and prefer the multiplatform stack (Ktor, kotlinx-serialization, kotlinx-datetime) over Android-only types.
|
|
14
14
|
- Changed files recorded; cross-module impacts noted.
|
|
15
15
|
- No full UI layouts or repository/API behavior.
|
|
16
16
|
|
|
@@ -71,8 +71,12 @@ ROLE: migration-prep node. Merge presentation + state/data prep in ONE invocatio
|
|
|
71
71
|
|
|
72
72
|
PRESENTATION: tokens, resources, media, routes, UI handoff.
|
|
73
73
|
STATE/DATA: state holders, models, mappers, API expectations, logic handoff.
|
|
74
|
+
State holder shape follows design_mode (default mvi): mvi → sealed State/Action + state machine
|
|
75
|
+
(references/kmp-mvi-flowredux.md); mvvm → UiState + ViewModel methods (references/kmp-mvvm.md).
|
|
76
|
+
Target is a KMP project per references/kmp-expert.md: scaffold in commonMain, platform actuals only in
|
|
77
|
+
androidMain/iosMain, prefer the multiplatform library stack over Android-only types.
|
|
74
78
|
|
|
75
|
-
INPUTS: migration_module_id, migration_planning_gate_path, analyst dimension paths,
|
|
79
|
+
INPUTS: design_mode, architecture_reference_path, migration_module_id, migration_planning_gate_path, analyst dimension paths,
|
|
76
80
|
target path, allowed_files, output_dir.
|
|
77
81
|
|
|
78
82
|
OUTPUTS: migration_prep.json, migration_prep.md
|
|
@@ -25,6 +25,17 @@ You merge **UI implementation** and **logic implementation** with strict modes.
|
|
|
25
25
|
|
|
26
26
|
**Gate**: `logic` mode MUST NOT run until latest UI review is `approved`.
|
|
27
27
|
|
|
28
|
+
## Design Mode (architecture pattern)
|
|
29
|
+
|
|
30
|
+
The run's `design_mode` (default `mvi`) is supplied by the Leader together with `architecture_reference_path`. You MUST implement to that pattern; do not mix patterns.
|
|
31
|
+
|
|
32
|
+
| `design_mode` | Reference | Shape you produce |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `mvi` **(default)** | `references/kmp-mvi-flowredux.md` | Sealed `State`/`Action`, `FlowReduxStateMachineFactory`, `dispatch()` from UI, unidirectional flow |
|
|
35
|
+
| `mvvm` | `references/kmp-mvvm.md` | `ViewModel` exposing immutable `UiState` as `StateFlow`, public event methods, `collectAsStateWithLifecycle()` |
|
|
36
|
+
|
|
37
|
+
Both modes follow `references/kmp-expert.md` for base KMP/CMP conventions. If `design_mode` or `architecture_reference_path` is missing from the dispatch, return `blocked` — do not guess the pattern.
|
|
38
|
+
|
|
28
39
|
## Success Criteria
|
|
29
40
|
|
|
30
41
|
**UI mode**:
|
|
@@ -54,7 +65,7 @@ You merge **UI implementation** and **logic implementation** with strict modes.
|
|
|
54
65
|
- Do not run full project compile/build — static edits only; build is `kmp-test-validator`.
|
|
55
66
|
|
|
56
67
|
**Mandatory**:
|
|
57
|
-
- Validate `kmp_target_project_path`, planning-gate `ready_for_implementation`, prep outputs, `target_module_anchors.json`, `allowed_files`, source sets, and workspace state before editing.
|
|
68
|
+
- Validate `kmp_target_project_path`, `design_mode` + `architecture_reference_path`, planning-gate `ready_for_implementation`, prep outputs, `target_module_anchors.json`, `allowed_files`, source sets, and workspace state before editing.
|
|
58
69
|
- Map each implementation task to a target path from planning/TPA before writing code.
|
|
59
70
|
- Include `mode`, `kmp_target_project_path`, and `changed_files` in JSON return payload.
|
|
60
71
|
- Write migration evidence artifacts under `output_dir`; write **implementation code** under `kmp_target_project_path`.
|
|
@@ -70,6 +81,8 @@ You merge **UI implementation** and **logic implementation** with strict modes.
|
|
|
70
81
|
"legacy_module_id": "",
|
|
71
82
|
"module_scope": {},
|
|
72
83
|
"kmp_target_project_path": "",
|
|
84
|
+
"design_mode": "mvi | mvvm",
|
|
85
|
+
"architecture_reference_path": "",
|
|
73
86
|
"output_root": "",
|
|
74
87
|
"output_dir": "",
|
|
75
88
|
"target_edit_summary": {
|
|
@@ -127,6 +140,11 @@ ROLE: module-implementation node in android-to-kmp-migrator. Modes: ui | logic.
|
|
|
127
140
|
YOU IMPLEMENT IN THE TARGET KMP PROJECT. Edit/create KMP files under kmp_target_project_path.
|
|
128
141
|
Legacy Android and analyst artifacts are read-only evidence. Do NOT edit Legacy Android.
|
|
129
142
|
|
|
143
|
+
DESIGN MODE: follow design_mode (default mvi). mvi → references/kmp-mvi-flowredux.md
|
|
144
|
+
(sealed State/Action + FlowReduxStateMachineFactory + dispatch); mvvm → references/kmp-mvvm.md
|
|
145
|
+
(ViewModel + immutable UiState StateFlow + public event methods). Both follow references/kmp-expert.md.
|
|
146
|
+
Do NOT mix patterns. If design_mode/architecture_reference_path missing, return blocked.
|
|
147
|
+
|
|
130
148
|
UI MODE:
|
|
131
149
|
- Port visible UI from upstream presentation evidence into target Compose/resources/navigation.
|
|
132
150
|
- changed_files = every target file you created or modified.
|
|
@@ -143,7 +161,7 @@ CONTROL:
|
|
|
143
161
|
- Map each task to a target path from planning source_to_target_map / TPA anchors first.
|
|
144
162
|
- If anchor or allowed_files is missing, return blocked — do not guess target paths.
|
|
145
163
|
|
|
146
|
-
INPUTS: mode, migration_module_id, legacy_module_id, kmp_target_project_path,
|
|
164
|
+
INPUTS: mode, design_mode, architecture_reference_path, migration_module_id, legacy_module_id, kmp_target_project_path,
|
|
147
165
|
migration_planning_gate_path, migration_prep_path, target_module_anchors_path,
|
|
148
166
|
upstream module_representation + presentation_resource/behavior_logic paths (read-only),
|
|
149
167
|
prior module_implementation_ui output (logic mode), allowed_files, output_dir.
|
|
@@ -11,6 +11,17 @@ You are the `module-node-review-fix` node subagent. You consolidate review and f
|
|
|
11
11
|
- `mode: review`: read-only review of one module/node slice.
|
|
12
12
|
- `mode: fix`: scoped edit of explicit `must_fix` findings from one review report.
|
|
13
13
|
|
|
14
|
+
## Design Mode (architecture pattern)
|
|
15
|
+
|
|
16
|
+
The run's `design_mode` (default `mvi`) and `architecture_reference_path` are supplied by the Leader. Both modes MUST judge/repair code against that pattern:
|
|
17
|
+
|
|
18
|
+
- `mvi` → `references/kmp-mvi-flowredux.md` (sealed `State`/`Action`, `FlowReduxStateMachineFactory`, `dispatch`).
|
|
19
|
+
- `mvvm` → `references/kmp-mvvm.md` (`ViewModel` + immutable `UiState` `StateFlow` + public event methods).
|
|
20
|
+
|
|
21
|
+
Both modes also judge code against `references/kmp-expert.md` base KMP/CMP conventions — correct source-set placement (`commonMain` vs `androidMain`/`iosMain`), `expect`/`actual` usage, no Android-only APIs leaked into `commonMain`, and multiplatform-stack choices.
|
|
22
|
+
|
|
23
|
+
Flag architecture drift (wrong pattern vs `design_mode`) or base-KMP violations as `must_fix` findings in review; in fix mode, conform to the references. Do not introduce the other pattern.
|
|
24
|
+
|
|
14
25
|
## Success Criteria
|
|
15
26
|
|
|
16
27
|
- Review mode writes `module_node_review.json` and `module_node_review.md`.
|
|
@@ -68,10 +79,15 @@ Shared return shape applies.
|
|
|
68
79
|
ROLE: module-node-review-fix node.
|
|
69
80
|
|
|
70
81
|
Respect mode strictly.
|
|
71
|
-
Review mode: read-only; verify one owning node slice for contract, scope, parity, source-set, target convention, dependency discipline, and handoff readiness.
|
|
82
|
+
Review mode: read-only; verify one owning node slice for contract, scope, parity, source-set, target convention, design_mode architecture conformance, dependency discipline, and handoff readiness.
|
|
72
83
|
Fix mode: consume one review report; fix only assigned must_fix findings inside allowed_files; set requires_re_review=true.
|
|
73
84
|
|
|
74
|
-
|
|
85
|
+
DESIGN MODE: judge/repair against design_mode (default mvi). mvi → references/kmp-mvi-flowredux.md;
|
|
86
|
+
mvvm → references/kmp-mvvm.md. Also enforce references/kmp-expert.md base KMP conventions (source-set
|
|
87
|
+
placement, expect/actual, no android.* in commonMain). Wrong-pattern or base-KMP violations are must_fix.
|
|
88
|
+
Never introduce the other pattern.
|
|
89
|
+
|
|
90
|
+
INPUTS: mode, design_mode, architecture_reference_path, migration_module_id, module_scope, owning_node, owning_node_output_path, changed_files, review_report_path for fix mode, allowed_files, workspace state, output_dir.
|
|
75
91
|
|
|
76
92
|
OUTPUTS:
|
|
77
93
|
- review mode: module_node_review.json/md (read-only reviewed files, findings, approval/needs-fix decision, blockers)
|
|
@@ -20,6 +20,8 @@ You are the `target-project-assistant` node subagent. You understand the existin
|
|
|
20
20
|
- `target_alignment_revision.json` exists after `global_baseline` with anchor points, `entry_point_anchors[]`, and revised alignment rows.
|
|
21
21
|
- Per-module `target_module_anchors.json` maps legacy evidence to resolvable target paths.
|
|
22
22
|
- `consult` responses reference prior alignment revision version and list affected anchor ids.
|
|
23
|
+
- `target_project_layout` notes the target's existing presentation pattern and whether it matches the run's `design_mode` (default `mvi`); a mismatch is surfaced in `integration_constraints[]` for the Leader, not silently resolved.
|
|
24
|
+
- `target_project_layout` is read against `references/kmp-expert.md` base KMP conventions — record the target's source-set hierarchy (`commonMain` / `androidMain` / `iosMain`), `shared` + `*App` module shape, and multiplatform stack so anchors resolve to the correct KMP source set; flag deviations from the base layout in `integration_constraints[]`.
|
|
23
25
|
- No target code edits (read-only analysis).
|
|
24
26
|
|
|
25
27
|
## Boundary
|
|
@@ -106,7 +108,14 @@ You own target KMP understanding and alignment revision. Modes:
|
|
|
106
108
|
|
|
107
109
|
You do NOT edit target code or run full builds. Other roles MUST use your artifacts.
|
|
108
110
|
|
|
109
|
-
|
|
111
|
+
DESIGN MODE: detect the target's existing presentation pattern and compare to design_mode (default mvi:
|
|
112
|
+
references/kmp-mvi-flowredux.md; mvvm: references/kmp-mvvm.md). Record the match/mismatch in
|
|
113
|
+
integration_constraints[] — do not resolve it yourself.
|
|
114
|
+
KMP BASE: read the target against references/kmp-expert.md — capture source-set hierarchy
|
|
115
|
+
(commonMain/androidMain/iosMain), shared + *App module shape, and stack so anchors resolve to the right
|
|
116
|
+
source set; flag base-layout deviations in integration_constraints[].
|
|
117
|
+
|
|
118
|
+
INPUTS: design_mode, architecture_reference_path, kmp_target_project_path, analyst_output_root, upstream_analyst_index_path, modules_index_path,
|
|
110
119
|
migration_assembly_basis_path, cross_module_architecture_path, cross_module_data_logic_path,
|
|
111
120
|
legacy module_representation paths, migration_module_id, mode, output_dir.
|
|
112
121
|
|
|
@@ -59,10 +59,19 @@ graph TD
|
|
|
59
59
|
## Step 0 — Pre-flight
|
|
60
60
|
|
|
61
61
|
- **Executor**: Leader
|
|
62
|
-
- **Input**: [dependencies.yaml](dependencies.yaml) — `tools[]` (`rg`, `git`, `curl`), `optional_mcp.jetbrains`, `upstream_inputs` analyst **P6**
|
|
63
|
-
- **Output**: `run_manifest.json` → `dependency_preflight` (CLI status, MCP availability, P6 readiness pointer)
|
|
62
|
+
- **Input**: [dependencies.yaml](dependencies.yaml) — `tools[]` (`rg`, `git`, `curl`), `optional_mcp.jetbrains`, `upstream_inputs` analyst **P6**; the **user input / migration request**
|
|
63
|
+
- **Output**: `run_manifest.json` → `dependency_preflight` (CLI status, MCP availability, P6 readiness pointer) and `design_mode` (architecture pattern decision)
|
|
64
64
|
- **Gate**: missing CLI tools → degraded modes per dependencies.yaml; `android-project-analyst` **P6** not ready → **blocked** — invoke analyst first, do not dispatch migrator nodes
|
|
65
65
|
|
|
66
|
+
### Step 0a — Identify design mode (default MVI)
|
|
67
|
+
|
|
68
|
+
- Scan the **user input** for an explicit or implied presentation architecture:
|
|
69
|
+
- **`mvvm`** signals: "MVVM", shared `ViewModel`, `StateFlow` / `uiState`, `viewModelScope`, `collectAsStateWithLifecycle`, KMP-ObservableViewModel, SKIE → `references/kmp-mvvm.md`
|
|
70
|
+
- **`mvi`** signals: "MVI", FlowRedux, state machine, reducer, intent, unidirectional, sealed `State`/`Action`, `dispatch`, `inState`, `onEnter` → `references/kmp-mvi-flowredux.md`
|
|
71
|
+
- **No clear signal → default `mvi`.**
|
|
72
|
+
- Record `design_mode: { value: "mvi | mvvm", source: "user_input | default", signals: [], architecture_reference_path: "" }` in `run_manifest.json`.
|
|
73
|
+
- Freeze `design_mode` for the run; pass `design_mode` + `architecture_reference_path` into every architecture-producing dispatch (planning-gate, prep, module-implementation, module-node-review-fix, global-migration-phase) and to TPA for target-pattern detection.
|
|
74
|
+
|
|
66
75
|
## Step 1 — Upstream + output root
|
|
67
76
|
|
|
68
77
|
- Verify analyst package **P6**; write `upstream_analyst_index.json`.
|
|
@@ -125,6 +134,7 @@ Any target question → TPA `mode: consult` (append `consultation_log`).
|
|
|
125
134
|
## Acceptance Criteria
|
|
126
135
|
|
|
127
136
|
- `android-project-analyst` **P6** verified before any migrator module dispatch.
|
|
137
|
+
- `design_mode` identified from user input at Step 0 (default `mvi`) and recorded in `run_manifest.json`; architecture-producing dispatches carry `design_mode` + `architecture_reference_path`.
|
|
128
138
|
- Target KMP files created or updated under `kmp_target_project_path` for every module requiring implementation; `target_changed_files[]` aggregated in `migration_report.json`.
|
|
129
139
|
- `kmp-test-validator` invoked after **V0** — mandatory MG17 step.
|
|
130
140
|
- Dispatch only role IDs from `SKILL.md`.
|