craft-native 0.0.276 → 0.0.278

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.
@@ -328,6 +328,142 @@ enum CraftNativeFiles {
328
328
  }
329
329
  }
330
330
 
331
+ // MARK: Picking a general file
332
+
333
+ /// Pick one or more documents and copy them into app-owned storage while
334
+ /// the security-scoped URLs are valid. `contentTypes` accepts MIME types,
335
+ /// UTIs, or filename extensions; an empty list means any document.
336
+ static func pickFile(_ options: [String: Any], done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
337
+ guard activePicker == nil else {
338
+ done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
339
+ return
340
+ }
341
+ let limit = craftNativeNumber(options["maxBytes"])
342
+ .map { $0.doubleValue }
343
+ .flatMap { value in
344
+ guard value.isFinite else { return nil }
345
+ return Int(max(1, min(Double(512 * 1024 * 1024), value)))
346
+ } ?? 512 * 1024 * 1024
347
+ let selectionLimit = craftNativeNumber(options["maxSelections"])
348
+ .map { $0.intValue }
349
+ .map { max(1, min(20, $0)) } ?? 1
350
+ let types: [UTType]
351
+ do {
352
+ types = try documentTypes(options["contentTypes"])
353
+ } catch let error as CraftNativeActionError {
354
+ done(.failure(error))
355
+ return
356
+ } catch {
357
+ done(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentTypes are invalid")))
358
+ return
359
+ }
360
+ let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
361
+ DispatchQueue.main.async { activePicker = nil; done(result) }
362
+ }
363
+ let delegate = FileDocumentDelegate(limit: limit, done: finish)
364
+ let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
365
+ picker.allowsMultipleSelection = selectionLimit > 1
366
+ picker.delegate = delegate
367
+ activePicker = delegate
368
+ if !CraftPresenter.present(picker, from: nil) {
369
+ activePicker = nil
370
+ done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the file picker")))
371
+ }
372
+ }
373
+
374
+ /// Resolve an app-supplied content filter without silently broadening it.
375
+ /// A MIME type, UTI, or extension is enough for `UTType`; applications can
376
+ /// therefore ask for `application/pdf`, `com.adobe.pdf`, or `pdf` alike.
377
+ private static func documentTypes(_ value: Any?) throws -> [UTType] {
378
+ guard let value else { return [.item] }
379
+ guard let entries = value as? [Any] else {
380
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentTypes must be an array")
381
+ }
382
+ guard !entries.isEmpty else { return [.item] }
383
+ var types: [UTType] = []
384
+ for entry in entries {
385
+ guard let raw = entry as? String else {
386
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentTypes must be strings")
387
+ }
388
+ let name = raw.trimmingCharacters(in: .whitespacesAndNewlines)
389
+ guard !name.isEmpty,
390
+ let type = UTType(mimeType: name) ?? UTType(name) ?? UTType(filenameExtension: name.trimmingCharacters(in: CharacterSet(charactersIn: "."))) else {
391
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentType is not a MIME type, UTI, or extension: \(raw)")
392
+ }
393
+ types.append(type)
394
+ }
395
+ return types
396
+ }
397
+
398
+ /// Copy an arbitrary selected document into Craft's private file area and
399
+ /// return the same descriptor used by downloads, writes, and uploads.
400
+ static func copyFile(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
401
+ do {
402
+ let values = try source.resourceValues(forKeys: [.fileSizeKey, .contentTypeKey])
403
+ let size: Int
404
+ if let fileSize = values.fileSize {
405
+ size = fileSize
406
+ } else {
407
+ size = ((try? FileManager.default.attributesOfItem(atPath: source.path))?[.size] as? NSNumber)?.intValue ?? 0
408
+ }
409
+ guard size > 0, size <= limit else {
410
+ return .failure(CraftNativeActionError(code: "FILE_TOO_LARGE", message: "Choose a nonempty file under \(limit / 1024 / 1024)MB."))
411
+ }
412
+ let destination = try newFile(named: source.lastPathComponent)
413
+ do { try FileManager.default.copyItem(at: source, to: destination) }
414
+ catch { try? FileManager.default.removeItem(at: destination.deletingLastPathComponent()); throw error }
415
+ let type = mimeType(for: source, contentType: values.contentType)
416
+ return .success(describe(destination, type: type))
417
+ } catch {
418
+ return .failure(CraftNativeActionError(code: "READ_ERROR", message: error.localizedDescription))
419
+ }
420
+ }
421
+
422
+ private static func mimeType(for source: URL, contentType: UTType?) -> String {
423
+ let generic = "application/octet-stream"
424
+ if let preferred = contentType?.preferredMIMEType, preferred != generic { return preferred }
425
+ if let preferred = UTType(filenameExtension: source.pathExtension)?.preferredMIMEType, preferred != generic {
426
+ return preferred
427
+ }
428
+ switch source.pathExtension.lowercased() {
429
+ case "gpx": return "application/gpx+xml"
430
+ case "tcx": return "application/vnd.garmin.tcx+xml"
431
+ case "fit": return "application/vnd.ant.fit"
432
+ case "csv": return "text/csv"
433
+ case "json": return "application/json"
434
+ case "txt": return "text/plain"
435
+ default: return generic
436
+ }
437
+ }
438
+
439
+ private final class FileDocumentDelegate: NSObject, UIDocumentPickerDelegate {
440
+ let limit: Int
441
+ let done: (Result<Any, CraftNativeActionError>) -> Void
442
+ init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
443
+ func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success([])) }
444
+ func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
445
+ guard !urls.isEmpty else { done(.success([])); return }
446
+ let (limit, done) = (self.limit, self.done)
447
+ DispatchQueue.global(qos: .userInitiated).async {
448
+ var results: [[String: Any]] = []
449
+ for url in urls {
450
+ let granted = url.startAccessingSecurityScopedResource()
451
+ defer { if granted { url.stopAccessingSecurityScopedResource() } }
452
+ switch CraftNativeFiles.copyFile(url, limit: limit) {
453
+ case .success(let value):
454
+ guard let info = value as? [String: Any] else { done(.failure(CraftNativeActionError(code: "READ_ERROR", message: "The selected file could not be described"))); return }
455
+ results.append(info)
456
+ case .failure(let error):
457
+ results.compactMap { $0["path"] as? String }.forEach { CraftNativeFiles.remove($0) }
458
+ done(.failure(error))
459
+ return
460
+ }
461
+ }
462
+ done(.success(results))
463
+ }
464
+ }
465
+ }
466
+
331
467
  static func copyVideo(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
332
468
  let ext = source.pathExtension.lowercased()
333
469
  let types = ["mp4": "video/mp4", "m4v": "video/x-m4v", "mov": "video/quicktime", "webm": "video/webm"]
@@ -115,6 +115,9 @@ final class CraftNativeMutationDocument {
115
115
  // A child crossing parents needs the committed tree to be
116
116
  // reconciled so the host can move its existing native view.
117
117
  requiresFullRender = true
118
+ case "replaceChildren":
119
+ let parentId = try replaceChildren(operation, nodes: &candidateNodes)
120
+ appendUnique(parentId, to: &affectedNodeIds)
118
121
  case "removeNode":
119
122
  if let id = operation["id"] as? String, let parent = candidateNodes[id]?.parent {
120
123
  appendUnique(parent, to: &affectedNodeIds)
@@ -287,6 +290,54 @@ final class CraftNativeMutationDocument {
287
290
  return previousParentId
288
291
  }
289
292
 
293
+ /// Replaces a container's ordered child snapshot in one atomic mutation.
294
+ ///
295
+ /// The list protocol uses this operation when a keyed `:for` expansion
296
+ /// produces a new row set. Existing nodes may stay attached to the same
297
+ /// parent; removed children become detached and can be reused or removed
298
+ /// by later operations in the same batch.
299
+ private func replaceChildren(_ operation: [String: Any], nodes: inout [String: Node]) throws -> String {
300
+ let parentId = try requiredId(operation, field: "parentId", name: "replaceChildren")
301
+ guard var parent = nodes[parentId] else {
302
+ throw CraftNativeMutationFailure("UNKNOWN_PARENT", "node \(parentId) does not exist")
303
+ }
304
+ guard acceptsChildren(parent.type) else {
305
+ throw CraftNativeMutationFailure("INVALID_PARENT", "node \(parentId) cannot contain child nodes")
306
+ }
307
+ guard let values = operation["children"] as? [Any] else {
308
+ throw CraftNativeMutationFailure("INVALID_CHILDREN", "replaceChildren.children must be an array")
309
+ }
310
+ var childIds: [String] = []
311
+ for value in values {
312
+ guard let childId = value as? String, !childId.isEmpty else {
313
+ throw CraftNativeMutationFailure("INVALID_CHILDREN", "replaceChildren.children must contain non-empty ids")
314
+ }
315
+ guard !childIds.contains(childId) else {
316
+ throw CraftNativeMutationFailure("DUPLICATE_CHILD", "child \(childId) appears more than once")
317
+ }
318
+ guard let child = nodes[childId] else {
319
+ throw CraftNativeMutationFailure("UNKNOWN_NODE", "node \(childId) does not exist")
320
+ }
321
+ guard childId != parentId, !descendants(of: childId, nodes: nodes).contains(parentId) else {
322
+ throw CraftNativeMutationFailure("CYCLE", "attaching node \(childId) would create a cycle")
323
+ }
324
+ guard child.parent == nil || child.parent == parentId else {
325
+ throw CraftNativeMutationFailure("NODE_ATTACHED", "node \(childId) already has a parent")
326
+ }
327
+ childIds.append(childId)
328
+ }
329
+ let removedChildIds = parent.children.filter { !childIds.contains($0) }
330
+ for oldChildId in removedChildIds {
331
+ for descendant in descendants(of: oldChildId, nodes: nodes).union([oldChildId]) {
332
+ nodes.removeValue(forKey: descendant)
333
+ }
334
+ }
335
+ parent.children = childIds
336
+ nodes[parentId] = parent
337
+ for childId in childIds { nodes[childId]?.parent = parentId }
338
+ return parentId
339
+ }
340
+
290
341
  private func removeNode(
291
342
  _ operation: [String: Any],
292
343
  nodes: inout [String: Node],
@@ -1,4 +1,5 @@
1
1
  import SwiftUI
2
+ import HealthKit
2
3
  import WatchConnectivity
3
4
 
4
5
  @main
@@ -15,14 +16,28 @@ struct {{APP_NAME}}WatchApp: App {
15
16
 
16
17
  @MainActor
17
18
  final class CraftWatchSession: NSObject, ObservableObject {
19
+ private static let protocolVersion = 1
20
+ private static let persistedStateKey = "craft.watch.state.v1"
21
+ private static let healthKitEnabled = {{HEALTH_KIT_ENABLED}}
22
+
18
23
  @Published private(set) var isPhoneReachable = false
19
24
  @Published private(set) var isRecording = false
20
25
  @Published private(set) var isPaused = false
21
26
  @Published private(set) var distanceMeters = 0.0
22
27
  @Published private(set) var durationSeconds = 0.0
23
28
  @Published private(set) var status = "Ready"
29
+ @Published private(set) var plannedSession: [String: Any]?
30
+ @Published private(set) var heartRateBpm: Double?
31
+ @Published private(set) var heartRateZone: Int?
32
+ @Published private(set) var lastWorkout: [String: Any]?
24
33
 
25
34
  private let session: WCSession?
35
+ private let healthStore = HKHealthStore()
36
+ private var stateVersion = 0
37
+ private var workoutSession: HKWorkoutSession?
38
+ private var workoutBuilder: HKLiveWorkoutBuilder?
39
+ private var workoutId: String?
40
+ private var workoutStartedAt: Date?
26
41
 
27
42
  override init() {
28
43
  if WCSession.isSupported() {
@@ -32,49 +47,320 @@ final class CraftWatchSession: NSObject, ObservableObject {
32
47
  }
33
48
  super.init()
34
49
  session?.delegate = self
50
+ restoreState()
35
51
  session?.activate()
36
52
  }
37
53
 
38
54
  func toggleRecording() {
39
- let action: String
40
55
  if !isRecording {
41
- action = "start"
56
+ if Self.healthKitEnabled {
57
+ startHealthWorkout { [weak self] started in
58
+ if started { self?.send(action: "start") }
59
+ }
60
+ } else {
61
+ send(action: "start")
62
+ }
42
63
  } else if isPaused {
43
- action = "resume"
64
+ resumeHealthWorkout()
65
+ send(action: "resume")
44
66
  } else {
45
- action = "pause"
67
+ pauseHealthWorkout()
68
+ send(action: "pause")
46
69
  }
47
- send(action: action)
48
70
  }
49
71
 
50
72
  func finishRecording() {
51
- send(action: "finish")
73
+ if Self.healthKitEnabled, workoutSession != nil {
74
+ endHealthWorkout { [weak self] _ in self?.send(action: "finish") }
75
+ } else {
76
+ send(action: "finish")
77
+ }
78
+ }
79
+
80
+ /// The phone sends a planned session through application context. Context
81
+ /// is the right transport here: it replaces stale plans while the watch is
82
+ /// offline and is replayed when the app launches again.
83
+ func clearPlannedSession() {
84
+ plannedSession = nil
85
+ persistState()
86
+ publishState()
52
87
  }
53
88
 
54
89
  private func send(action: String) {
55
90
  guard let session, session.activationState == .activated else {
56
91
  status = "Open WildLoop on iPhone"
92
+ persistState()
57
93
  return
58
94
  }
59
- let message: [String: Any] = ["type": "recording-control", "action": action]
95
+ let message: [String: Any] = [
96
+ "protocolVersion": Self.protocolVersion,
97
+ "requestId": UUID().uuidString,
98
+ "type": "recording-control",
99
+ "action": action,
100
+ ]
60
101
  if session.isReachable {
61
102
  session.sendMessage(message, replyHandler: { [weak self] reply in
62
103
  Task { @MainActor in self?.apply(reply) }
63
104
  }, errorHandler: { [weak self] _ in
64
- Task { @MainActor in self?.status = "Could not reach iPhone" }
105
+ Task { @MainActor in
106
+ self?.status = "Could not reach iPhone"
107
+ self?.persistState()
108
+ self?.publishState()
109
+ }
65
110
  })
66
111
  } else {
67
112
  session.transferUserInfo(message)
68
113
  status = "Command queued"
114
+ persistState()
115
+ publishState()
69
116
  }
70
117
  }
71
118
 
72
119
  private func apply(_ payload: [String: Any]) {
120
+ if let plan = payload["plan"] as? [String: Any], payload["type"] as? String == "planned-session" {
121
+ plannedSession = plan
122
+ status = "Plan ready"
123
+ } else if let plan = payload["plannedSession"] as? [String: Any] {
124
+ plannedSession = plan
125
+ }
73
126
  if let recording = payload["recording"] as? Bool { isRecording = recording }
74
127
  if let paused = payload["paused"] as? Bool { isPaused = paused }
75
128
  if let distance = payload["distanceMeters"] as? Double { distanceMeters = max(distance, 0) }
76
129
  if let duration = payload["durationSeconds"] as? Double { durationSeconds = max(duration, 0) }
77
130
  if let value = payload["status"] as? String { status = value }
131
+ if let heartRate = payload["heartRateBpm"] as? Double, heartRate.isFinite { heartRateBpm = max(heartRate, 0) }
132
+ if let zone = payload["heartRateZone"] as? Int { heartRateZone = min(max(zone, 1), 5) }
133
+ if let workout = payload["workout"] as? [String: Any] { lastWorkout = workout }
134
+ persistState()
135
+ publishState()
136
+ }
137
+
138
+ private func restoreState() {
139
+ guard let state = UserDefaults.standard.dictionary(forKey: Self.persistedStateKey) else { return }
140
+ if let recording = state["recording"] as? Bool { isRecording = recording }
141
+ if let paused = state["paused"] as? Bool { isPaused = paused }
142
+ if let distance = state["distanceMeters"] as? Double { distanceMeters = max(distance, 0) }
143
+ if let duration = state["durationSeconds"] as? Double { durationSeconds = max(duration, 0) }
144
+ if let value = state["status"] as? String { status = value }
145
+ if let version = state["stateVersion"] as? Int { stateVersion = max(version, stateVersion) }
146
+ plannedSession = state["plannedSession"] as? [String: Any]
147
+ if let heartRate = state["heartRateBpm"] as? Double { heartRateBpm = max(heartRate, 0) }
148
+ if let zone = state["heartRateZone"] as? Int { heartRateZone = min(max(zone, 1), 5) }
149
+ lastWorkout = state["lastWorkout"] as? [String: Any]
150
+ }
151
+
152
+ private func persistState() {
153
+ stateVersion += 1
154
+ var state: [String: Any] = [
155
+ "protocolVersion": Self.protocolVersion,
156
+ "stateVersion": stateVersion,
157
+ "recording": isRecording,
158
+ "paused": isPaused,
159
+ "distanceMeters": distanceMeters,
160
+ "durationSeconds": durationSeconds,
161
+ "status": status,
162
+ ]
163
+ if let plannedSession { state["plannedSession"] = plannedSession }
164
+ if let heartRateBpm { state["heartRateBpm"] = heartRateBpm }
165
+ if let heartRateZone { state["heartRateZone"] = heartRateZone }
166
+ if let lastWorkout { state["lastWorkout"] = lastWorkout }
167
+ UserDefaults.standard.set(state, forKey: Self.persistedStateKey)
168
+ }
169
+
170
+ private func publishState() {
171
+ guard let session, session.activationState == .activated else { return }
172
+ var state: [String: Any] = [
173
+ "protocolVersion": Self.protocolVersion,
174
+ "stateVersion": stateVersion,
175
+ "type": "watch-state",
176
+ "recording": isRecording,
177
+ "paused": isPaused,
178
+ "distanceMeters": distanceMeters,
179
+ "durationSeconds": durationSeconds,
180
+ "status": status,
181
+ ]
182
+ if let plannedSession { state["plannedSession"] = plannedSession }
183
+ if let heartRateBpm { state["heartRateBpm"] = heartRateBpm }
184
+ if let heartRateZone { state["heartRateZone"] = heartRateZone }
185
+ if let lastWorkout { state["workout"] = lastWorkout }
186
+ try? session.updateApplicationContext(state)
187
+ }
188
+
189
+ private func startHealthWorkout(completion: @escaping (Bool) -> Void) {
190
+ guard HKHealthStore.isHealthDataAvailable() else {
191
+ status = "HealthKit unavailable"
192
+ persistState()
193
+ completion(false)
194
+ return
195
+ }
196
+ let shareTypes: Set<HKSampleType> = [HKObjectType.workoutType()]
197
+ var readTypes: Set<HKObjectType> = [HKObjectType.workoutType()]
198
+ if let heartRate = HKQuantityType.quantityType(forIdentifier: .heartRate) { readTypes.insert(heartRate) }
199
+ if let distance = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning) { readTypes.insert(distance) }
200
+ if let energy = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) { readTypes.insert(energy) }
201
+ healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { [weak self] success, error in
202
+ DispatchQueue.main.async {
203
+ guard let self else { return completion(false) }
204
+ guard success else {
205
+ self.status = error?.localizedDescription ?? "HealthKit authorization denied"
206
+ self.persistState()
207
+ return completion(false)
208
+ }
209
+ self.beginHealthWorkout(completion: completion)
210
+ }
211
+ }
212
+ }
213
+
214
+ private func beginHealthWorkout(completion: @escaping (Bool) -> Void) {
215
+ guard workoutSession == nil else { completion(true); return }
216
+ let configuration = HKWorkoutConfiguration()
217
+ configuration.activityType = .running
218
+ configuration.locationType = .outdoor
219
+ do {
220
+ let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
221
+ let builder = session.associatedWorkoutBuilder()
222
+ session.delegate = self
223
+ builder.delegate = self
224
+ builder.shouldCollectWorkoutEvents = true
225
+ builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
226
+ workoutSession = session
227
+ workoutBuilder = builder
228
+ workoutId = UUID().uuidString.lowercased()
229
+ let start = Date()
230
+ workoutStartedAt = start
231
+ session.startActivity(with: start)
232
+ builder.beginCollection(withStart: start) { [weak self] success, error in
233
+ DispatchQueue.main.async {
234
+ guard let self else { return completion(false) }
235
+ guard success else {
236
+ self.clearHealthWorkout()
237
+ self.status = error?.localizedDescription ?? "Workout could not start"
238
+ self.persistState()
239
+ return completion(false)
240
+ }
241
+ self.isRecording = true
242
+ self.isPaused = false
243
+ self.status = "Recording"
244
+ self.persistState()
245
+ self.publishState()
246
+ completion(true)
247
+ }
248
+ }
249
+ } catch {
250
+ status = error.localizedDescription
251
+ persistState()
252
+ completion(false)
253
+ }
254
+ }
255
+
256
+ private func pauseHealthWorkout() {
257
+ guard let workoutSession else { return }
258
+ workoutSession.pause()
259
+ isPaused = true
260
+ status = "Paused"
261
+ persistState()
262
+ publishState()
263
+ }
264
+
265
+ private func resumeHealthWorkout() {
266
+ guard let workoutSession else { return }
267
+ workoutSession.resume()
268
+ isPaused = false
269
+ status = "Recording"
270
+ persistState()
271
+ publishState()
272
+ }
273
+
274
+ private func endHealthWorkout(completion: @escaping ([String: Any]?) -> Void) {
275
+ guard let workoutSession, let workoutBuilder, let id = workoutId else {
276
+ completion(nil)
277
+ return
278
+ }
279
+ let end = Date()
280
+ workoutSession.end()
281
+ workoutBuilder.endCollection(withEnd: end) { [weak self] success, error in
282
+ guard let self else { return }
283
+ guard success else {
284
+ DispatchQueue.main.async {
285
+ self.status = error?.localizedDescription ?? "Workout could not finish"
286
+ self.persistState()
287
+ completion(nil)
288
+ }
289
+ return
290
+ }
291
+ workoutBuilder.finishWorkout { [weak self] workout, finishError in
292
+ DispatchQueue.main.async {
293
+ guard let self else { return completion(nil) }
294
+ guard let workout else {
295
+ self.status = finishError?.localizedDescription ?? "Workout could not be saved"
296
+ self.persistState()
297
+ return completion(nil)
298
+ }
299
+ let result: [String: Any] = [
300
+ "id": id,
301
+ "workoutId": workout.uuid.uuidString,
302
+ "startDate": (self.workoutStartedAt ?? workout.startDate).timeIntervalSince1970 * 1000,
303
+ "endDate": end.timeIntervalSince1970 * 1000,
304
+ "durationSeconds": workout.duration,
305
+ "distanceMeters": self.distanceMeters,
306
+ ]
307
+ self.lastWorkout = result
308
+ self.isRecording = false
309
+ self.isPaused = false
310
+ self.status = "Saved to Apple Health"
311
+ self.clearHealthWorkout()
312
+ self.persistState()
313
+ self.publishState()
314
+ completion(result)
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ private func clearHealthWorkout() {
321
+ workoutSession = nil
322
+ workoutBuilder = nil
323
+ workoutId = nil
324
+ workoutStartedAt = nil
325
+ }
326
+
327
+ private func updateHealthMetrics(_ builder: HKLiveWorkoutBuilder) {
328
+ if let heartRate = HKQuantityType.quantityType(forIdentifier: .heartRate),
329
+ let value = builder.statistics(for: heartRate)?.mostRecentQuantity()?.doubleValue(for: HKUnit.count().unitDivided(by: .minute())),
330
+ value.isFinite {
331
+ heartRateBpm = max(value, 0)
332
+ let maximum = (plannedSession?["maxHeartRateBpm"] as? Double) ?? 190
333
+ heartRateZone = min(max(Int(ceil(value / maximum * 5)), 1), 5)
334
+ }
335
+ if let distance = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning),
336
+ let value = builder.statistics(for: distance)?.sumQuantity()?.doubleValue(for: .meter()),
337
+ value.isFinite {
338
+ distanceMeters = max(value, 0)
339
+ }
340
+ if let start = workoutStartedAt { durationSeconds = max(Date().timeIntervalSince(start), 0) }
341
+ persistState()
342
+ publishState()
343
+ }
344
+ }
345
+
346
+ extension CraftWatchSession: HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate {
347
+ nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {}
348
+
349
+ nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
350
+ Task { @MainActor in
351
+ status = error.localizedDescription
352
+ isRecording = false
353
+ isPaused = false
354
+ clearHealthWorkout()
355
+ persistState()
356
+ publishState()
357
+ }
358
+ }
359
+
360
+ nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
361
+
362
+ nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf types: Set<HKSampleType>) {
363
+ Task { @MainActor in updateHealthMetrics(workoutBuilder) }
78
364
  }
79
365
  }
80
366
 
@@ -84,11 +370,15 @@ extension CraftWatchSession: WCSessionDelegate {
84
370
  isPhoneReachable = session.isReachable
85
371
  status = error == nil ? "Ready" : "Connection unavailable"
86
372
  apply(session.receivedApplicationContext)
373
+ publishState()
87
374
  }
88
375
  }
89
376
 
90
377
  nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
91
- Task { @MainActor in isPhoneReachable = session.isReachable }
378
+ Task { @MainActor in
379
+ isPhoneReachable = session.isReachable
380
+ if session.isReachable { publishState() }
381
+ }
92
382
  }
93
383
 
94
384
  nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
@@ -129,6 +419,10 @@ struct CraftWatchRecordingView: View {
129
419
  metric(distance, label: "Distance")
130
420
  metric(duration, label: "Time")
131
421
  }
422
+ if let heartRate = session.heartRateBpm {
423
+ metric(String(format: "%.0f bpm", heartRate), label: "Heart rate")
424
+ if let zone = session.heartRateZone { Text("Zone \(zone) of 5").font(.caption2).foregroundStyle(.secondary) }
425
+ }
132
426
  Button(session.isRecording && !session.isPaused ? "Pause" : session.isPaused ? "Resume" : "Start") {
133
427
  session.toggleRecording()
134
428
  }
@@ -30,5 +30,6 @@
30
30
  <key>NSExtensionPointIdentifier</key>
31
31
  <string>com.apple.watchkit</string>
32
32
  </dict>
33
+ {{HEALTH_USAGE_DESCRIPTIONS}}
33
34
  </dict>
34
35
  </plist>
@@ -115,6 +115,9 @@ final class CraftNativeMutationDocument {
115
115
  // A child crossing parents needs the committed tree to be
116
116
  // reconciled so the host can move its existing native view.
117
117
  requiresFullRender = true
118
+ case "replaceChildren":
119
+ let parentId = try replaceChildren(operation, nodes: &candidateNodes)
120
+ appendUnique(parentId, to: &affectedNodeIds)
118
121
  case "removeNode":
119
122
  if let id = operation["id"] as? String, let parent = candidateNodes[id]?.parent {
120
123
  appendUnique(parent, to: &affectedNodeIds)
@@ -287,6 +290,54 @@ final class CraftNativeMutationDocument {
287
290
  return previousParentId
288
291
  }
289
292
 
293
+ /// Replaces a container's ordered child snapshot in one atomic mutation.
294
+ ///
295
+ /// The list protocol uses this operation when a keyed `:for` expansion
296
+ /// produces a new row set. Existing nodes may stay attached to the same
297
+ /// parent; removed children become detached and can be reused or removed
298
+ /// by later operations in the same batch.
299
+ private func replaceChildren(_ operation: [String: Any], nodes: inout [String: Node]) throws -> String {
300
+ let parentId = try requiredId(operation, field: "parentId", name: "replaceChildren")
301
+ guard var parent = nodes[parentId] else {
302
+ throw CraftNativeMutationFailure("UNKNOWN_PARENT", "node \(parentId) does not exist")
303
+ }
304
+ guard acceptsChildren(parent.type) else {
305
+ throw CraftNativeMutationFailure("INVALID_PARENT", "node \(parentId) cannot contain child nodes")
306
+ }
307
+ guard let values = operation["children"] as? [Any] else {
308
+ throw CraftNativeMutationFailure("INVALID_CHILDREN", "replaceChildren.children must be an array")
309
+ }
310
+ var childIds: [String] = []
311
+ for value in values {
312
+ guard let childId = value as? String, !childId.isEmpty else {
313
+ throw CraftNativeMutationFailure("INVALID_CHILDREN", "replaceChildren.children must contain non-empty ids")
314
+ }
315
+ guard !childIds.contains(childId) else {
316
+ throw CraftNativeMutationFailure("DUPLICATE_CHILD", "child \(childId) appears more than once")
317
+ }
318
+ guard let child = nodes[childId] else {
319
+ throw CraftNativeMutationFailure("UNKNOWN_NODE", "node \(childId) does not exist")
320
+ }
321
+ guard childId != parentId, !descendants(of: childId, nodes: nodes).contains(parentId) else {
322
+ throw CraftNativeMutationFailure("CYCLE", "attaching node \(childId) would create a cycle")
323
+ }
324
+ guard child.parent == nil || child.parent == parentId else {
325
+ throw CraftNativeMutationFailure("NODE_ATTACHED", "node \(childId) already has a parent")
326
+ }
327
+ childIds.append(childId)
328
+ }
329
+ let removedChildIds = parent.children.filter { !childIds.contains($0) }
330
+ for oldChildId in removedChildIds {
331
+ for descendant in descendants(of: oldChildId, nodes: nodes).union([oldChildId]) {
332
+ nodes.removeValue(forKey: descendant)
333
+ }
334
+ }
335
+ parent.children = childIds
336
+ nodes[parentId] = parent
337
+ for childId in childIds { nodes[childId]?.parent = parentId }
338
+ return parentId
339
+ }
340
+
290
341
  private func removeNode(
291
342
  _ operation: [String: Any],
292
343
  nodes: inout [String: Node],