@mmerterden/multi-agent-pipeline 20.35.0 → 20.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +6 -5
- package/README.tr.md +6 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +4 -4
- package/install/catalog-history.json +1 -1
- package/manifest.json +210 -136
- package/package.json +5 -1
- package/pipeline/commands/multi-agent/SKILL.md +33 -154
- package/pipeline/commands/multi-agent/analysis/SKILL.md +14 -68
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +5 -8
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +38 -109
- package/pipeline/commands/multi-agent/channels/SKILL.md +38 -167
- package/pipeline/commands/multi-agent/complaint-analysis/SKILL.md +37 -144
- package/pipeline/commands/multi-agent/design-check/SKILL.md +27 -131
- package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +22 -48
- package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +37 -180
- package/pipeline/commands/multi-agent/jira/SKILL.md +16 -46
- package/pipeline/commands/multi-agent/office-pair/SKILL.md +66 -0
- package/pipeline/commands/multi-agent/office-status/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/refactor/SKILL.md +23 -184
- package/pipeline/commands/multi-agent/resume/SKILL.md +30 -136
- package/pipeline/commands/multi-agent/review/SKILL.md +24 -199
- package/pipeline/commands/multi-agent/setup/SKILL.md +25 -181
- package/pipeline/commands/multi-agent/stack/SKILL.md +9 -53
- package/pipeline/commands/multi-agent/status/SKILL.md +13 -2
- package/pipeline/commands/multi-agent/store-ready/SKILL.md +27 -226
- package/pipeline/commands/multi-agent/sync/SKILL.md +38 -167
- package/pipeline/commands/multi-agent/test-gen/SKILL.md +2 -4
- package/pipeline/commands/multi-agent/update/SKILL.md +18 -60
- package/pipeline/contract/CHANGELOG.md +9 -0
- package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
- package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
- package/pipeline/contract/fixtures/error-unsigned.json +1 -1
- package/pipeline/contract/fixtures/runs-awaiting-question.json +1 -1
- package/pipeline/contract/fixtures/runs-empty.json +1 -1
- package/pipeline/contract/fixtures/runs-failed.json +1 -1
- package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +1 -1
- package/pipeline/contract/fixtures/runs-pr-opened.json +1 -1
- package/pipeline/contract/fixtures/runs-running.json +1 -1
- package/pipeline/contract/manifest.json +5 -5
- package/pipeline/contract/types/index.d.ts +3 -2
- package/pipeline/lib/qr-code.mjs +26 -0
- package/pipeline/multi-agent-refs/autopilot-on/details.md +148 -0
- package/pipeline/multi-agent-refs/channels/generator.md +14 -0
- package/pipeline/multi-agent-refs/channels/preview.md +13 -0
- package/pipeline/multi-agent-refs/channels/reference.md +46 -0
- package/pipeline/multi-agent-refs/complaint-analysis/phases.md +159 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
- package/pipeline/multi-agent-refs/design-check/export.md +1 -0
- package/pipeline/multi-agent-refs/design-check/init-inventory.md +1 -1
- package/pipeline/multi-agent-refs/design-check/scope-and-ledger.md +64 -0
- package/pipeline/multi-agent-refs/features/analysis-command-notes.md +53 -0
- package/pipeline/multi-agent-refs/garbage-collect/phases.md +60 -0
- package/pipeline/multi-agent-refs/help/en.md +1 -0
- package/pipeline/multi-agent-refs/help/tr.md +1 -0
- package/pipeline/multi-agent-refs/ios-coding-standard/plan.md +71 -0
- package/pipeline/multi-agent-refs/ios-coding-standard/resolve-and-scan.md +135 -0
- package/pipeline/multi-agent-refs/jira/picker-detail.md +71 -0
- package/pipeline/multi-agent-refs/orchestrator/copilot-pipeline.md +88 -0
- package/pipeline/multi-agent-refs/orchestrator/dispatcher.md +96 -1
- package/pipeline/multi-agent-refs/refactor/sweep.md +190 -0
- package/pipeline/multi-agent-refs/resume/details.md +115 -2
- package/pipeline/multi-agent-refs/review/inputs.md +33 -1
- package/pipeline/multi-agent-refs/review/module-guides.md +9 -1
- package/pipeline/multi-agent-refs/review/panel.md +75 -0
- package/pipeline/multi-agent-refs/review/valven.md +5 -1
- package/pipeline/multi-agent-refs/setup/hooks-and-plugins.md +4 -0
- package/pipeline/multi-agent-refs/setup/token-save-flow.md +13 -0
- package/pipeline/multi-agent-refs/stack/background.md +72 -0
- package/pipeline/multi-agent-refs/store-ready/gates.md +139 -0
- package/pipeline/multi-agent-refs/store-ready/report.md +40 -2
- package/pipeline/multi-agent-refs/sync/office.md +10 -0
- package/pipeline/multi-agent-refs/sync/overview.md +61 -0
- package/pipeline/multi-agent-refs/sync/repo.md +91 -0
- package/pipeline/multi-agent-refs/update/details.md +83 -0
- package/pipeline/schemas/runs-index.schema.json +2 -1
- package/pipeline/scripts/contract-server.mjs +1 -1
- package/pipeline/scripts/office-pair.mjs +40 -9
- package/pipeline/scripts/runs-index.mjs +34 -7
- package/pipeline/scripts/status-overview.mjs +401 -0
- package/pipeline/skills/.skill-manifest.json +69 -65
- package/pipeline/skills/.skills-index.json +15 -4
- package/pipeline/skills/shared/README.md +4 -3
- package/pipeline/skills/shared/core/apple-archive-compliance/SKILL.md +8 -71
- package/pipeline/skills/shared/core/apple-archive-compliance/references/examples.md +67 -0
- package/pipeline/skills/shared/core/apple-archive-compliance/references/version-history.md +8 -0
- package/pipeline/skills/shared/core/google-play-compliance/SKILL.md +11 -73
- package/pipeline/skills/shared/core/google-play-compliance/references/examples.md +69 -0
- package/pipeline/skills/shared/core/google-play-compliance/references/notes.md +7 -0
- package/pipeline/skills/shared/core/google-play-compliance/references/version-history.md +5 -0
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +39 -262
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +35 -73
- package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +37 -337
- package/pipeline/skills/shared/core/multi-agent-design-check/SKILL.md +27 -122
- package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +8 -340
- package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +37 -180
- package/pipeline/skills/shared/core/multi-agent-office-pair/SKILL.md +19 -0
- package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-refactor/SKILL.md +23 -184
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +14 -82
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +25 -277
- package/pipeline/skills/shared/core/multi-agent-status/SKILL.md +13 -2
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +75 -219
- package/pipeline/skills/shared/external/alarmkit/SKILL.md +11 -162
- package/pipeline/skills/shared/external/alarmkit/references/alarmkit-code.md +257 -0
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +7 -58
- package/pipeline/skills/shared/external/android-architecture/references/code-examples.md +68 -0
- package/pipeline/skills/shared/external/app-intents/SKILL.md +3 -45
- package/pipeline/skills/shared/external/app-intents/references/code-examples.md +54 -0
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +5 -81
- package/pipeline/skills/shared/external/app-store-optimization/references/engagement-and-localization.md +96 -0
- package/pipeline/skills/shared/external/app-store-review/SKILL.md +6 -112
- package/pipeline/skills/shared/external/app-store-review/references/monetization-and-submission.md +138 -0
- package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +10 -117
- package/pipeline/skills/shared/external/apple-on-device-ai/references/foundation-models-code.md +186 -0
- package/pipeline/skills/shared/external/authentication/SKILL.md +3 -50
- package/pipeline/skills/shared/external/authentication/references/code-examples.md +59 -0
- package/pipeline/skills/shared/external/callkit-voip/SKILL.md +3 -84
- package/pipeline/skills/shared/external/callkit-voip/references/code-examples.md +93 -0
- package/pipeline/skills/shared/external/ci-cd-pipelines/SKILL.md +7 -263
- package/pipeline/skills/shared/external/ci-cd-pipelines/references/pipeline-examples.md +281 -0
- package/pipeline/skills/shared/external/clean-code/SKILL.md +8 -41
- package/pipeline/skills/shared/external/clean-code/references/code-examples.md +56 -0
- package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +8 -146
- package/pipeline/skills/shared/external/cloudkit-sync/references/cloudkit-code.md +214 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +6 -80
- package/pipeline/skills/shared/external/compose-components/references/code-examples.md +89 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +5 -64
- package/pipeline/skills/shared/external/compose-navigation/references/code-examples.md +70 -0
- package/pipeline/skills/shared/external/core-data/SKILL.md +9 -157
- package/pipeline/skills/shared/external/core-data/references/core-data-code.md +221 -0
- package/pipeline/skills/shared/external/core-nfc/SKILL.md +3 -89
- package/pipeline/skills/shared/external/core-nfc/references/code-examples.md +98 -0
- package/pipeline/skills/shared/external/coreml/SKILL.md +17 -133
- package/pipeline/skills/shared/external/coreml/references/coreml-code.md +208 -0
- package/pipeline/skills/shared/external/css-modern/SKILL.md +8 -131
- package/pipeline/skills/shared/external/css-modern/references/code-examples.md +142 -0
- package/pipeline/skills/shared/external/database-patterns/SKILL.md +6 -103
- package/pipeline/skills/shared/external/database-patterns/references/orm-examples.md +112 -0
- package/pipeline/skills/shared/external/html-semantic/SKILL.md +7 -48
- package/pipeline/skills/shared/external/html-semantic/references/code-examples.md +54 -0
- package/pipeline/skills/shared/external/humanizer/SKILL.md +6 -19
- package/pipeline/skills/shared/external/humanizer/references/voice.md +24 -0
- package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +13 -170
- package/pipeline/skills/shared/external/ios-accessibility/references/assistive-tech-review.md +224 -0
- package/pipeline/skills/shared/external/ios-localization/SKILL.md +2 -24
- package/pipeline/skills/shared/external/ios-localization/references/code-examples.md +30 -0
- package/pipeline/skills/shared/external/ios-networking/SKILL.md +3 -58
- package/pipeline/skills/shared/external/ios-networking/references/code-examples.md +67 -0
- package/pipeline/skills/shared/external/ios-security/SKILL.md +4 -64
- package/pipeline/skills/shared/external/ios-security/references/code-examples.md +74 -0
- package/pipeline/skills/shared/external/live-activities/SKILL.md +4 -90
- package/pipeline/skills/shared/external/live-activities/references/code-examples.md +108 -0
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +9 -23
- package/pipeline/skills/shared/external/localization-reuse-map/references/project-settings.md +22 -0
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +5 -95
- package/pipeline/skills/shared/external/mapkit-location/references/code-examples.md +117 -0
- package/pipeline/skills/shared/external/nextjs-app-router/SKILL.md +7 -106
- package/pipeline/skills/shared/external/nextjs-app-router/references/app-router-examples.md +124 -0
- package/pipeline/skills/shared/external/permissionkit/SKILL.md +2 -31
- package/pipeline/skills/shared/external/permissionkit/references/code-examples.md +37 -0
- package/pipeline/skills/shared/external/play-store-review/SKILL.md +6 -74
- package/pipeline/skills/shared/external/play-store-review/references/signing-and-policies.md +83 -0
- package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -125
- package/pipeline/skills/shared/external/push-notifications/references/notification-code.md +176 -0
- package/pipeline/skills/shared/external/python-patterns/SKILL.md +7 -158
- package/pipeline/skills/shared/external/python-patterns/references/python-examples.md +176 -0
- package/pipeline/skills/shared/external/rest-api-design/SKILL.md +7 -157
- package/pipeline/skills/shared/external/rest-api-design/references/api-examples.md +175 -0
- package/pipeline/skills/shared/external/swift-architecture/SKILL.md +11 -230
- package/pipeline/skills/shared/external/swift-architecture/references/pattern-examples.md +295 -0
- package/pipeline/skills/shared/external/swift-codable/SKILL.md +17 -149
- package/pipeline/skills/shared/external/swift-codable/references/codable-recipes.md +207 -0
- package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +12 -99
- package/pipeline/skills/shared/external/swift-concurrency/references/code-examples.md +100 -0
- package/pipeline/skills/shared/external/swift-concurrency/references/swift-6-2-additions.md +18 -0
- package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +9 -78
- package/pipeline/skills/shared/external/swift-formatstyle/references/formatstyle-examples.md +113 -0
- package/pipeline/skills/shared/external/swift-language/SKILL.md +3 -56
- package/pipeline/skills/shared/external/swift-language/references/code-examples.md +65 -0
- package/pipeline/skills/shared/external/swift-testing-pro/SKILL.md +1 -0
- package/pipeline/skills/shared/external/swiftdata/SKILL.md +6 -122
- package/pipeline/skills/shared/external/swiftdata/references/code-examples.md +140 -0
- package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +12 -139
- package/pipeline/skills/shared/external/swiftui-uikit-interop/references/interop-code.md +198 -0
- package/pipeline/skills/shared/external/tailwind-css/SKILL.md +9 -81
- package/pipeline/skills/shared/external/tailwind-css/references/code-examples.md +91 -0
- package/pipeline/skills/shared/external/testing-backend/SKILL.md +7 -207
- package/pipeline/skills/shared/external/testing-backend/references/testing-examples.md +225 -0
- package/pipeline/skills/shared/external/typescript-patterns/SKILL.md +8 -89
- package/pipeline/skills/shared/external/typescript-patterns/references/code-examples.md +100 -0
- package/pipeline/skills/shared/external/vision-framework/SKILL.md +2 -20
- package/pipeline/skills/shared/external/vision-framework/references/code-examples.md +26 -0
- package/pipeline/skills/shared/external/vue-composition/SKILL.md +7 -169
- package/pipeline/skills/shared/external/vue-composition/references/vue-examples.md +187 -0
- package/pipeline/skills/shared/external/web-accessibility/SKILL.md +7 -174
- package/pipeline/skills/shared/external/web-accessibility/references/a11y-examples.md +192 -0
- package/pipeline/skills/shared/external/web-performance/SKILL.md +8 -66
- package/pipeline/skills/shared/external/web-performance/references/code-examples.md +77 -0
- package/pipeline/skills/shared/external/web-testing/SKILL.md +11 -186
- package/pipeline/skills/shared/external/web-testing/references/testing-examples.md +213 -0
- package/pipeline/skills/skills-index.md +2 -1
- /package/pipeline/skills/shared/external/localization-reuse-map/{reference → references}/format-and-output.md +0 -0
- /package/pipeline/skills/shared/external/localization-reuse-map/{reference → references}/publish-and-snapshot.md +0 -0
- /package/pipeline/skills/shared/external/localization-reuse-map/{reference → references}/sources-and-recipes.md +0 -0
|
@@ -124,191 +124,33 @@ transformations of the same shared state.
|
|
|
124
124
|
A view model sits between the view and the model: it prepares display values
|
|
125
125
|
and receives user actions, and the view watches it.
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
struct WorkoutRow: Identifiable {
|
|
129
|
-
let id: Workout.ID
|
|
130
|
-
let title: String
|
|
131
|
-
let when: String
|
|
132
|
-
|
|
133
|
-
init(_ workout: Workout) {
|
|
134
|
-
id = workout.id
|
|
135
|
-
title = workout.title
|
|
136
|
-
when = workout.start.formatted(.dateTime.month().day())
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
@MainActor
|
|
141
|
-
@Observable
|
|
142
|
-
final class WorkoutListModel {
|
|
143
|
-
private(set) var rows: [WorkoutRow] = []
|
|
144
|
-
private(set) var loading = false
|
|
145
|
-
var filter = ""
|
|
146
|
-
|
|
147
|
-
private var all: [Workout] = []
|
|
148
|
-
private let store: any WorkoutStore
|
|
149
|
-
|
|
150
|
-
init(store: any WorkoutStore) { self.store = store }
|
|
151
|
-
|
|
152
|
-
var visibleRows: [WorkoutRow] {
|
|
153
|
-
filter.isEmpty ? rows : rows.filter { $0.title.localizedStandardContains(filter) }
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
func load() async {
|
|
157
|
-
loading = true
|
|
158
|
-
defer { loading = false }
|
|
159
|
-
all = (try? await store.allWorkouts()) ?? []
|
|
160
|
-
rows = all.map(WorkoutRow.init)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
func delete(rowsAt positions: IndexSet) async {
|
|
164
|
-
let doomed = positions.map { visibleRows[$0].id }
|
|
165
|
-
for id in doomed { try? await store.delete(id) }
|
|
166
|
-
await load()
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
struct WorkoutListScreen: View {
|
|
171
|
-
@State private var model: WorkoutListModel
|
|
172
|
-
|
|
173
|
-
init(store: any WorkoutStore) {
|
|
174
|
-
_model = State(initialValue: WorkoutListModel(store: store))
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
var body: some View {
|
|
178
|
-
List {
|
|
179
|
-
ForEach(model.visibleRows, id: \.id) { row in
|
|
180
|
-
LabeledContent(row.title, value: row.when)
|
|
181
|
-
}
|
|
182
|
-
.onDelete { positions in Task { await model.delete(rowsAt: positions) } }
|
|
183
|
-
}
|
|
184
|
-
.searchable(text: $model.filter)
|
|
185
|
-
.task { await model.load() }
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
```
|
|
127
|
+
The payoff is a view model you can test without any view.
|
|
189
128
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
```swift
|
|
193
|
-
@Test @MainActor
|
|
194
|
-
func filterMatchesTitle() async {
|
|
195
|
-
let model = WorkoutListModel(store: FakeWorkoutStore(titles: ["Row", "Run", "Swim"]))
|
|
196
|
-
await model.load()
|
|
197
|
-
model.filter = "ru"
|
|
198
|
-
#expect(model.visibleRows.count == 1)
|
|
199
|
-
}
|
|
200
|
-
```
|
|
129
|
+
Code for this section: [Architecture pattern examples](references/pattern-examples.md).
|
|
201
130
|
|
|
202
131
|
## MVI
|
|
203
132
|
|
|
204
133
|
Data moves one way. The view emits intents, a single function applies each
|
|
205
134
|
intent to the state, and any work with side effects is spelled out there.
|
|
206
135
|
|
|
207
|
-
```swift
|
|
208
|
-
@MainActor
|
|
209
|
-
@Observable
|
|
210
|
-
final class WorkoutFeed {
|
|
211
|
-
struct State {
|
|
212
|
-
var sessions: [Workout] = []
|
|
213
|
-
var loading = false
|
|
214
|
-
var message: String?
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
enum Intent {
|
|
218
|
-
case appeared
|
|
219
|
-
case remove(Workout.ID)
|
|
220
|
-
case dismissMessage
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
private(set) var state = State()
|
|
224
|
-
private let store: any WorkoutStore
|
|
225
|
-
|
|
226
|
-
init(store: any WorkoutStore) { self.store = store }
|
|
227
|
-
|
|
228
|
-
func send(_ intent: Intent) {
|
|
229
|
-
Task { await apply(intent) }
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
private func apply(_ next: Intent) async {
|
|
233
|
-
switch next {
|
|
234
|
-
case .appeared:
|
|
235
|
-
state.loading = true
|
|
236
|
-
do { state.sessions = try await store.allWorkouts() }
|
|
237
|
-
catch { state.message = error.localizedDescription }
|
|
238
|
-
state.loading = false
|
|
239
|
-
case .remove(let id):
|
|
240
|
-
do {
|
|
241
|
-
try await store.delete(id)
|
|
242
|
-
state.sessions.removeAll { $0.id == id }
|
|
243
|
-
} catch {
|
|
244
|
-
state.message = error.localizedDescription
|
|
245
|
-
}
|
|
246
|
-
case .dismissMessage:
|
|
247
|
-
state.message = nil
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
```
|
|
252
|
-
|
|
253
136
|
What it buys: every transition is predictable, intents can be logged and
|
|
254
137
|
replayed, and "what happened" (the intent) is kept apart from "what changed"
|
|
255
138
|
(the state).
|
|
256
139
|
|
|
140
|
+
Code for this section: [Architecture pattern examples](references/pattern-examples.md).
|
|
141
|
+
|
|
257
142
|
## TCA
|
|
258
143
|
|
|
259
144
|
The Composable Architecture is an open-source library. Features are reducers
|
|
260
145
|
that compose; dependencies are injected through a registry; effects are values
|
|
261
146
|
the store runs; tests can assert every state change exhaustively.
|
|
262
147
|
|
|
263
|
-
```swift
|
|
264
|
-
import ComposableArchitecture
|
|
265
|
-
|
|
266
|
-
@Reducer
|
|
267
|
-
struct WorkoutsFeature {
|
|
268
|
-
@ObservableState
|
|
269
|
-
struct State: Equatable {
|
|
270
|
-
var sessions: IdentifiedArrayOf<Workout> = []
|
|
271
|
-
var loading = false
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
enum Action {
|
|
275
|
-
case onAppear
|
|
276
|
-
case loaded([Workout])
|
|
277
|
-
case removeTapped(Workout.ID)
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
@Dependency(\.workoutClient) var workoutClient
|
|
281
|
-
|
|
282
|
-
var body: some ReducerOf<Self> {
|
|
283
|
-
Reduce { state, action in
|
|
284
|
-
switch action {
|
|
285
|
-
case .onAppear where !state.loading:
|
|
286
|
-
state.loading = true
|
|
287
|
-
return .run { send in
|
|
288
|
-
let sessions = try await workoutClient.fetchAll()
|
|
289
|
-
await send(.loaded(sessions))
|
|
290
|
-
}
|
|
291
|
-
case .loaded(let sessions):
|
|
292
|
-
state.loading = false
|
|
293
|
-
state.sessions = IdentifiedArray(uniqueElements: sessions)
|
|
294
|
-
return .none
|
|
295
|
-
case .onAppear:
|
|
296
|
-
return .none
|
|
297
|
-
case .removeTapped(let id):
|
|
298
|
-
state.sessions.remove(id: id)
|
|
299
|
-
return .run { [id] _ in
|
|
300
|
-
try await workoutClient.delete(id)
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
```
|
|
307
|
-
|
|
308
148
|
Choose it for complex flows that must be deterministic, effects that need
|
|
309
149
|
sequencing, features composed from smaller features, reducer-level testing, and
|
|
310
150
|
dependency injection across the whole app.
|
|
311
151
|
|
|
152
|
+
Code for this section: [Architecture pattern examples](references/pattern-examples.md).
|
|
153
|
+
|
|
312
154
|
## Clean Architecture
|
|
313
155
|
|
|
314
156
|
Three layers with dependencies pointing inward:
|
|
@@ -318,51 +160,12 @@ Three layers with dependencies pointing inward:
|
|
|
318
160
|
- **Data**: repository implementations, networking, persistence.
|
|
319
161
|
- **Presentation**: views and view models.
|
|
320
162
|
|
|
321
|
-
```swift
|
|
322
|
-
// Domain
|
|
323
|
-
protocol WorkoutRepository: Sendable {
|
|
324
|
-
func fetchAll() async throws -> [Workout]
|
|
325
|
-
func save(_ workout: Workout) async throws
|
|
326
|
-
func delete(_ id: Workout.ID) async throws
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
struct UpcomingWorkouts: Sendable {
|
|
330
|
-
let repository: any WorkoutRepository
|
|
331
|
-
|
|
332
|
-
func callAsFunction(after now: Date = .now) async throws -> [Workout] {
|
|
333
|
-
let everything = try await repository.fetchAll()
|
|
334
|
-
return everything
|
|
335
|
-
.filter { $0.start > now }
|
|
336
|
-
.sorted { $0.start < $1.start }
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Data
|
|
341
|
-
struct RemoteWorkoutRepository: WorkoutRepository {
|
|
342
|
-
let api: FitnessAPI
|
|
343
|
-
|
|
344
|
-
func fetchAll() async throws -> [Workout] { try await api.get("/workouts") }
|
|
345
|
-
func save(_ workout: Workout) async throws { try await api.post("/workouts", body: workout) }
|
|
346
|
-
func delete(_ id: Workout.ID) async throws { try await api.delete("/workouts/\(id)") }
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// Presentation
|
|
350
|
-
@MainActor
|
|
351
|
-
@Observable
|
|
352
|
-
final class PlannerModel {
|
|
353
|
-
private(set) var upcoming: [Workout] = []
|
|
354
|
-
private let upcomingWorkouts: UpcomingWorkouts
|
|
355
|
-
|
|
356
|
-
init(upcomingWorkouts: UpcomingWorkouts) { self.upcomingWorkouts = upcomingWorkouts }
|
|
357
|
-
|
|
358
|
-
func load() async { upcoming = (try? await upcomingWorkouts()) ?? [] }
|
|
359
|
-
}
|
|
360
|
-
```
|
|
361
|
-
|
|
362
163
|
Reach for it when rules or regulation demand hard separation, when domain code
|
|
363
164
|
has to be tested with no framework present, or when an app, a widget and a
|
|
364
165
|
watch app all run one set of business rules.
|
|
365
166
|
|
|
167
|
+
Code for this section: [Architecture pattern examples](references/pattern-examples.md).
|
|
168
|
+
|
|
366
169
|
## Coordinator pattern
|
|
367
170
|
|
|
368
171
|
Coordinators take navigation out of views and controllers. They pay off mostly
|
|
@@ -374,35 +177,12 @@ in UIKit and hybrid apps.
|
|
|
374
177
|
coordinator decides whether to push or present.
|
|
375
178
|
- Feature models keep the business logic. The coordinator only routes.
|
|
376
179
|
|
|
377
|
-
```swift
|
|
378
|
-
@MainActor
|
|
379
|
-
final class OnboardingCoordinator {
|
|
380
|
-
private let navigation: UINavigationController
|
|
381
|
-
private let account: AccountService
|
|
382
|
-
|
|
383
|
-
init(navigation: UINavigationController, account: AccountService) {
|
|
384
|
-
self.navigation = navigation
|
|
385
|
-
self.account = account
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
func start() {
|
|
389
|
-
let welcome = WelcomeViewController(onContinue: { [weak self] in self?.showSignUp() })
|
|
390
|
-
navigation.setViewControllers([welcome], animated: false)
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
private func showSignUp() {
|
|
394
|
-
let form = SignUpViewController(account: account, onDone: { [weak self] in self?.finish() })
|
|
395
|
-
navigation.pushViewController(form, animated: true)
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
private func finish() { navigation.dismiss(animated: true) }
|
|
399
|
-
}
|
|
400
|
-
```
|
|
401
|
-
|
|
402
180
|
In a pure SwiftUI app, a `NavigationStack` driven by a path usually does this
|
|
403
181
|
job without a coordinator. Keep coordinators for UIKit integration or for
|
|
404
182
|
navigation logic shared across platforms.
|
|
405
183
|
|
|
184
|
+
Code for this section: [Architecture pattern examples](references/pattern-examples.md).
|
|
185
|
+
|
|
406
186
|
## VIPER
|
|
407
187
|
|
|
408
188
|
View, Interactor, Presenter, Entity, Router. Its value today is keeping
|
|
@@ -477,6 +257,7 @@ while adoption is in progress.
|
|
|
477
257
|
|
|
478
258
|
## References
|
|
479
259
|
|
|
260
|
+
- [Architecture pattern examples](references/pattern-examples.md): open when implementing or reviewing a feature in MVVM, MVI, TCA, Clean Architecture or with coordinators.
|
|
480
261
|
- [Observation](https://developer.apple.com/documentation/observation)
|
|
481
262
|
- [Observable()](https://developer.apple.com/documentation/observation/observable())
|
|
482
263
|
- [Migrating from the Observable Object protocol to the Observable macro](https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# Architecture pattern examples
|
|
2
|
+
|
|
3
|
+
<!-- toc -->
|
|
4
|
+
- [MVVM](#mvvm)
|
|
5
|
+
- [MVI](#mvi)
|
|
6
|
+
- [TCA](#tca)
|
|
7
|
+
- [Clean Architecture](#clean-architecture)
|
|
8
|
+
- [Coordinator pattern](#coordinator-pattern)
|
|
9
|
+
<!-- /toc -->
|
|
10
|
+
|
|
11
|
+
Worked code for MVVM, MVI, TCA, Clean Architecture and the Coordinator pattern. SKILL.md keeps the selection guidance and the rules for each pattern.
|
|
12
|
+
|
|
13
|
+
## MVVM
|
|
14
|
+
|
|
15
|
+
A view model sits between the view and the model: it prepares display values
|
|
16
|
+
and receives user actions, and the view watches it.
|
|
17
|
+
|
|
18
|
+
```swift
|
|
19
|
+
struct WorkoutRow: Identifiable {
|
|
20
|
+
let id: Workout.ID
|
|
21
|
+
let title: String
|
|
22
|
+
let when: String
|
|
23
|
+
|
|
24
|
+
init(_ workout: Workout) {
|
|
25
|
+
id = workout.id
|
|
26
|
+
title = workout.title
|
|
27
|
+
when = workout.start.formatted(.dateTime.month().day())
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@MainActor
|
|
32
|
+
@Observable
|
|
33
|
+
final class WorkoutListModel {
|
|
34
|
+
private(set) var rows: [WorkoutRow] = []
|
|
35
|
+
private(set) var loading = false
|
|
36
|
+
var filter = ""
|
|
37
|
+
|
|
38
|
+
private var all: [Workout] = []
|
|
39
|
+
private let store: any WorkoutStore
|
|
40
|
+
|
|
41
|
+
init(store: any WorkoutStore) { self.store = store }
|
|
42
|
+
|
|
43
|
+
var visibleRows: [WorkoutRow] {
|
|
44
|
+
filter.isEmpty ? rows : rows.filter { $0.title.localizedStandardContains(filter) }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func load() async {
|
|
48
|
+
loading = true
|
|
49
|
+
defer { loading = false }
|
|
50
|
+
all = (try? await store.allWorkouts()) ?? []
|
|
51
|
+
rows = all.map(WorkoutRow.init)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func delete(rowsAt positions: IndexSet) async {
|
|
55
|
+
let doomed = positions.map { visibleRows[$0].id }
|
|
56
|
+
for id in doomed { try? await store.delete(id) }
|
|
57
|
+
await load()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
struct WorkoutListScreen: View {
|
|
62
|
+
@State private var model: WorkoutListModel
|
|
63
|
+
|
|
64
|
+
init(store: any WorkoutStore) {
|
|
65
|
+
_model = State(initialValue: WorkoutListModel(store: store))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
var body: some View {
|
|
69
|
+
List {
|
|
70
|
+
ForEach(model.visibleRows, id: \.id) { row in
|
|
71
|
+
LabeledContent(row.title, value: row.when)
|
|
72
|
+
}
|
|
73
|
+
.onDelete { positions in Task { await model.delete(rowsAt: positions) } }
|
|
74
|
+
}
|
|
75
|
+
.searchable(text: $model.filter)
|
|
76
|
+
.task { await model.load() }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The payoff is a view model you can test without any view:
|
|
82
|
+
|
|
83
|
+
```swift
|
|
84
|
+
@Test @MainActor
|
|
85
|
+
func filterMatchesTitle() async {
|
|
86
|
+
let model = WorkoutListModel(store: FakeWorkoutStore(titles: ["Row", "Run", "Swim"]))
|
|
87
|
+
await model.load()
|
|
88
|
+
model.filter = "ru"
|
|
89
|
+
#expect(model.visibleRows.count == 1)
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## MVI
|
|
94
|
+
|
|
95
|
+
Data moves one way. The view emits intents, a single function applies each
|
|
96
|
+
intent to the state, and any work with side effects is spelled out there.
|
|
97
|
+
|
|
98
|
+
```swift
|
|
99
|
+
@MainActor
|
|
100
|
+
@Observable
|
|
101
|
+
final class WorkoutFeed {
|
|
102
|
+
struct State {
|
|
103
|
+
var sessions: [Workout] = []
|
|
104
|
+
var loading = false
|
|
105
|
+
var message: String?
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
enum Intent {
|
|
109
|
+
case appeared
|
|
110
|
+
case remove(Workout.ID)
|
|
111
|
+
case dismissMessage
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private(set) var state = State()
|
|
115
|
+
private let store: any WorkoutStore
|
|
116
|
+
|
|
117
|
+
init(store: any WorkoutStore) { self.store = store }
|
|
118
|
+
|
|
119
|
+
func send(_ intent: Intent) {
|
|
120
|
+
Task { await apply(intent) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private func apply(_ next: Intent) async {
|
|
124
|
+
switch next {
|
|
125
|
+
case .appeared:
|
|
126
|
+
state.loading = true
|
|
127
|
+
do { state.sessions = try await store.allWorkouts() }
|
|
128
|
+
catch { state.message = error.localizedDescription }
|
|
129
|
+
state.loading = false
|
|
130
|
+
case .remove(let id):
|
|
131
|
+
do {
|
|
132
|
+
try await store.delete(id)
|
|
133
|
+
state.sessions.removeAll { $0.id == id }
|
|
134
|
+
} catch {
|
|
135
|
+
state.message = error.localizedDescription
|
|
136
|
+
}
|
|
137
|
+
case .dismissMessage:
|
|
138
|
+
state.message = nil
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
What it buys: every transition is predictable, intents can be logged and
|
|
145
|
+
replayed, and "what happened" (the intent) is kept apart from "what changed"
|
|
146
|
+
(the state).
|
|
147
|
+
|
|
148
|
+
## TCA
|
|
149
|
+
|
|
150
|
+
The Composable Architecture is an open-source library. Features are reducers
|
|
151
|
+
that compose; dependencies are injected through a registry; effects are values
|
|
152
|
+
the store runs; tests can assert every state change exhaustively.
|
|
153
|
+
|
|
154
|
+
```swift
|
|
155
|
+
import ComposableArchitecture
|
|
156
|
+
|
|
157
|
+
@Reducer
|
|
158
|
+
struct WorkoutsFeature {
|
|
159
|
+
@ObservableState
|
|
160
|
+
struct State: Equatable {
|
|
161
|
+
var sessions: IdentifiedArrayOf<Workout> = []
|
|
162
|
+
var loading = false
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
enum Action {
|
|
166
|
+
case onAppear
|
|
167
|
+
case loaded([Workout])
|
|
168
|
+
case removeTapped(Workout.ID)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@Dependency(\.workoutClient) var workoutClient
|
|
172
|
+
|
|
173
|
+
var body: some ReducerOf<Self> {
|
|
174
|
+
Reduce { state, action in
|
|
175
|
+
switch action {
|
|
176
|
+
case .onAppear where !state.loading:
|
|
177
|
+
state.loading = true
|
|
178
|
+
return .run { send in
|
|
179
|
+
let sessions = try await workoutClient.fetchAll()
|
|
180
|
+
await send(.loaded(sessions))
|
|
181
|
+
}
|
|
182
|
+
case .loaded(let sessions):
|
|
183
|
+
state.loading = false
|
|
184
|
+
state.sessions = IdentifiedArray(uniqueElements: sessions)
|
|
185
|
+
return .none
|
|
186
|
+
case .onAppear:
|
|
187
|
+
return .none
|
|
188
|
+
case .removeTapped(let id):
|
|
189
|
+
state.sessions.remove(id: id)
|
|
190
|
+
return .run { [id] _ in
|
|
191
|
+
try await workoutClient.delete(id)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Choose it for complex flows that must be deterministic, effects that need
|
|
200
|
+
sequencing, features composed from smaller features, reducer-level testing, and
|
|
201
|
+
dependency injection across the whole app.
|
|
202
|
+
|
|
203
|
+
## Clean Architecture
|
|
204
|
+
|
|
205
|
+
Three layers with dependencies pointing inward:
|
|
206
|
+
|
|
207
|
+
- **Domain**: the entities, the use cases, and the protocols repositories must
|
|
208
|
+
satisfy. It imports no UI or persistence framework.
|
|
209
|
+
- **Data**: repository implementations, networking, persistence.
|
|
210
|
+
- **Presentation**: views and view models.
|
|
211
|
+
|
|
212
|
+
```swift
|
|
213
|
+
// Domain
|
|
214
|
+
protocol WorkoutRepository: Sendable {
|
|
215
|
+
func fetchAll() async throws -> [Workout]
|
|
216
|
+
func save(_ workout: Workout) async throws
|
|
217
|
+
func delete(_ id: Workout.ID) async throws
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
struct UpcomingWorkouts: Sendable {
|
|
221
|
+
let repository: any WorkoutRepository
|
|
222
|
+
|
|
223
|
+
func callAsFunction(after now: Date = .now) async throws -> [Workout] {
|
|
224
|
+
let everything = try await repository.fetchAll()
|
|
225
|
+
return everything
|
|
226
|
+
.filter { $0.start > now }
|
|
227
|
+
.sorted { $0.start < $1.start }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Data
|
|
232
|
+
struct RemoteWorkoutRepository: WorkoutRepository {
|
|
233
|
+
let api: FitnessAPI
|
|
234
|
+
|
|
235
|
+
func fetchAll() async throws -> [Workout] { try await api.get("/workouts") }
|
|
236
|
+
func save(_ workout: Workout) async throws { try await api.post("/workouts", body: workout) }
|
|
237
|
+
func delete(_ id: Workout.ID) async throws { try await api.delete("/workouts/\(id)") }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Presentation
|
|
241
|
+
@MainActor
|
|
242
|
+
@Observable
|
|
243
|
+
final class PlannerModel {
|
|
244
|
+
private(set) var upcoming: [Workout] = []
|
|
245
|
+
private let upcomingWorkouts: UpcomingWorkouts
|
|
246
|
+
|
|
247
|
+
init(upcomingWorkouts: UpcomingWorkouts) { self.upcomingWorkouts = upcomingWorkouts }
|
|
248
|
+
|
|
249
|
+
func load() async { upcoming = (try? await upcomingWorkouts()) ?? [] }
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Reach for it when rules or regulation demand hard separation, when domain code
|
|
254
|
+
has to be tested with no framework present, or when an app, a widget and a
|
|
255
|
+
watch app all run one set of business rules.
|
|
256
|
+
|
|
257
|
+
## Coordinator pattern
|
|
258
|
+
|
|
259
|
+
Coordinators take navigation out of views and controllers. They pay off mostly
|
|
260
|
+
in UIKit and hybrid apps.
|
|
261
|
+
|
|
262
|
+
- A coordinator is `@MainActor` and receives its dependencies when it is
|
|
263
|
+
created.
|
|
264
|
+
- View models and view controllers report user choices through callbacks; the
|
|
265
|
+
coordinator decides whether to push or present.
|
|
266
|
+
- Feature models keep the business logic. The coordinator only routes.
|
|
267
|
+
|
|
268
|
+
```swift
|
|
269
|
+
@MainActor
|
|
270
|
+
final class OnboardingCoordinator {
|
|
271
|
+
private let navigation: UINavigationController
|
|
272
|
+
private let account: AccountService
|
|
273
|
+
|
|
274
|
+
init(navigation: UINavigationController, account: AccountService) {
|
|
275
|
+
self.navigation = navigation
|
|
276
|
+
self.account = account
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func start() {
|
|
280
|
+
let welcome = WelcomeViewController(onContinue: { [weak self] in self?.showSignUp() })
|
|
281
|
+
navigation.setViewControllers([welcome], animated: false)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private func showSignUp() {
|
|
285
|
+
let form = SignUpViewController(account: account, onDone: { [weak self] in self?.finish() })
|
|
286
|
+
navigation.pushViewController(form, animated: true)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private func finish() { navigation.dismiss(animated: true) }
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
In a pure SwiftUI app, a `NavigationStack` driven by a path usually does this
|
|
294
|
+
job without a coordinator. Keep coordinators for UIKit integration or for
|
|
295
|
+
navigation logic shared across platforms.
|