@code-migration/wow-migrator 0.2.3 → 0.2.5
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-project-analyst/SKILL.md +7 -5
- package/skills/android-project-analyst/dependencies.yaml +11 -1
- package/skills/android-to-kmp-migrator/SKILL.md +31 -10
- package/skills/android-to-kmp-migrator/bind.md +4 -0
- package/skills/android-to-kmp-migrator/dependencies.yaml +14 -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/skills/kmp-test-validator/SKILL.md +7 -5
- package/skills/kmp-test-validator/dependencies.yaml +10 -0
- package/skills/migration-task-adapter/SKILL.md +5 -3
- package/skills/migration-task-adapter/dependencies.yaml +7 -0
- package/skills/operating-instructions/SKILL.md +55 -0
|
@@ -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 |
|