craft-native 0.0.163 → 0.0.166

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.
@@ -614,12 +614,16 @@ final class CraftChrome: ObservableObject {
614
614
  @Published var tabBarVisible = false
615
615
  @Published var splashVisible = true
616
616
  @Published var tint: UIColor?
617
+ /// Whether a hybrid page lets the shell claim horizontal swipes for tabs.
618
+ /// Native screens use their own per-screen `tabSwipeEnabled` option.
619
+ private(set) var tabSwipeEnabled = true
617
620
 
618
621
  weak var webView: WKWebView?
619
622
  /// What the tab bar covers at the bottom of the screen, in points.
620
623
  private var occupied: CGFloat = 0
621
624
  private var pendingHide: DispatchWorkItem?
622
625
  private var splashDeadline: DispatchWorkItem?
626
+ private var tabSwipePrevious: String?
623
627
  /// The keyboard is up and covers the tab bar, so the page need not leave
624
628
  /// room for it: the web view ends at the keyboard (CraftHybrid's webHost).
625
629
  private var keyboardUp = false
@@ -645,6 +649,7 @@ final class CraftChrome: ObservableObject {
645
649
  /// false when the first screen is native and draws itself.
646
650
  func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool) {
647
651
  splashVisible = splash
652
+ tabSwipeEnabled = true
648
653
  guard !configured.isEmpty else { return }
649
654
  tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil) }
650
655
  self.selected = selected ?? configured.first?.id
@@ -692,6 +697,7 @@ final class CraftChrome: ObservableObject {
692
697
  let dark = (body["tintDark"] as? String).flatMap { UIColor(hex: $0) }
693
698
  tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
694
699
  }
700
+ if let swipeEnabled = body["swipeEnabled"] as? Bool { tabSwipeEnabled = swipeEnabled }
695
701
  withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty && !hiddenByScreen }
696
702
  publishLayout()
697
703
  case "selectTab":
@@ -738,6 +744,41 @@ final class CraftChrome: ObservableObject {
738
744
  emit("craftTabSelect", ["id": tab.id])
739
745
  }
740
746
 
747
+ /// A completed horizontal swipe selects the adjacent tab. The shell owns
748
+ /// this path so it works over both native screens and the shared WebView.
749
+ /// The gesture recognizer yields before calling this for a screen-owned
750
+ /// horizontal scroll or custom pager.
751
+ func swipe(to id: String) {
752
+ guard id != selected, tabs.contains(where: { $0.id == id }) else { return }
753
+ withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = id }
754
+ UISelectionFeedbackGenerator().selectionChanged()
755
+ guard CraftHybrid.shared.tabTapped(id, reselect: false) else { return }
756
+ emit("craftTabSelect", ["id": id])
757
+ }
758
+
759
+ /// Move the tab indicator with an in-flight native-stack swipe. The
760
+ /// controller commits or restores this preview when the finger lifts.
761
+ func beginTabSwipe(to id: String) {
762
+ guard id != selected, tabs.contains(where: { $0.id == id }) else { return }
763
+ tabSwipePrevious = selected
764
+ withAnimation(nil) { selected = id }
765
+ }
766
+
767
+ func cancelTabSwipe() {
768
+ guard let previous = tabSwipePrevious else { return }
769
+ tabSwipePrevious = nil
770
+ withAnimation(nil) { selected = previous }
771
+ }
772
+
773
+ func commitTabSwipe(to id: String) {
774
+ tabSwipePrevious = nil
775
+ guard tabs.contains(where: { $0.id == id }) else { return }
776
+ withAnimation(nil) { selected = id }
777
+ UISelectionFeedbackGenerator().selectionChanged()
778
+ guard CraftHybrid.shared.tabTapped(id, reselect: false) else { return }
779
+ emit("craftTabSelect", ["id": id])
780
+ }
781
+
741
782
  /// The animation, or none with Reduce Motion on: the selection jumps to
742
783
  /// its tab rather than springing there, as UIKit's own controls do.
743
784
  static func motion(_ animation: Animation) -> Animation? {
@@ -1740,6 +1781,17 @@ struct CraftWebView: UIViewRepresentable {
1740
1781
  if config.enableMotionSensors {
1741
1782
  motionManager = CMMotionManager()
1742
1783
  }
1784
+ if config.enableHealthKit {
1785
+ // Apple Health's heart rate as it arrives
1786
+ // (craft.health.startHeartRateUpdates) reaches the page, and
1787
+ // through sendToWeb every native screen too. False once this
1788
+ // coordinator is gone: native screens still hear it.
1789
+ CraftHealth.shared.pageSink = { [weak self] detail in
1790
+ guard let self else { return false }
1791
+ self.sendToWeb("craftHealthHeartRate", data: detail)
1792
+ return true
1793
+ }
1794
+ }
1743
1795
  if config.enableHeartRate {
1744
1796
  // The strap's readings and connection changes reach the page,
1745
1797
  // and through sendToWeb every native screen too. False once
@@ -2514,6 +2566,21 @@ struct CraftWebView: UIViewRepresentable {
2514
2566
  } else {
2515
2567
  rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2516
2568
  }
2569
+ case "saveHealthData":
2570
+ if config.enableHealthKit {
2571
+ guard let dataType = body["type"] as? String,
2572
+ let value = (body["value"] as? NSNumber)?.doubleValue else {
2573
+ rejectCallback(callbackId, error: "saveHealthData was called without a type and numeric value", code: "INVALID_ARGUMENT")
2574
+ break
2575
+ }
2576
+ CraftHealth.shared.saveData(
2577
+ type: dataType,
2578
+ value: value,
2579
+ options: body
2580
+ ) { [weak self] in self?.answer(callbackId, $0) }
2581
+ } else {
2582
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2583
+ }
2517
2584
  case "saveHealthWorkout":
2518
2585
  if config.enableHealthKit {
2519
2586
  saveHealthWorkout(body: body, callbackId: callbackId)
@@ -2583,6 +2650,20 @@ struct CraftWebView: UIViewRepresentable {
2583
2650
  } else {
2584
2651
  rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2585
2652
  }
2653
+ // Heart rate as Apple Health receives it: craftHealthHeartRate
2654
+ // events until stopped (CraftHealth.swift).
2655
+ case "startHealthHeartRateUpdates":
2656
+ if config.enableHealthKit {
2657
+ CraftHealth.shared.startHeartRateUpdates(since: body["since"] as? Double) { [weak self] in self?.answer(callbackId, $0) }
2658
+ } else {
2659
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2660
+ }
2661
+ case "stopHealthHeartRateUpdates":
2662
+ if config.enableHealthKit {
2663
+ CraftHealth.shared.stopHeartRateUpdates { [weak self] in self?.answer(callbackId, $0) }
2664
+ } else {
2665
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2666
+ }
2586
2667
 
2587
2668
  // MARK: - Live Activities
2588
2669
  case "startLiveActivity":
@@ -5077,6 +5158,10 @@ struct CraftWebView: UIViewRepresentable {
5077
5158
  options = options || {};
5078
5159
  return craft._invoke('getHealthData', {type: type, startDate: options.startDate, endDate: options.endDate});
5079
5160
  },
5161
+ saveData: function(type, value, options) {
5162
+ options = options || {};
5163
+ return craft._invoke('saveHealthData', {type: type, value: value, startDate: options.startDate, endDate: options.endDate});
5164
+ },
5080
5165
  saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); },
5081
5166
  getWorkouts: function(options) {
5082
5167
  options = options || {};
@@ -5101,7 +5186,14 @@ struct CraftWebView: UIViewRepresentable {
5101
5186
  getMindfulSessions: function(options) {
5102
5187
  options = options || {};
5103
5188
  return craft._invoke('getHealthMindfulSessions', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
5104
- }
5189
+ },
5190
+ // Heart rate as Apple Health receives it, as
5191
+ // craftHealthHeartRate events ({samples}) until stopped.
5192
+ startHeartRateUpdates: function(options) {
5193
+ var since = options && typeof options.since === 'number' ? options.since : null;
5194
+ return craft._invoke('startHealthHeartRateUpdates', {since: since});
5195
+ },
5196
+ stopHeartRateUpdates: function() { return craft._invoke('stopHealthHeartRateUpdates'); }
5105
5197
  };
5106
5198
  // The Music app's song and transport. Nothing is read until
5107
5199
  // requestAuthorization() has been allowed; observe(true) makes
@@ -8661,7 +8753,7 @@ final class CraftZigRuntime: NSObject {
8661
8753
  enum CraftWebServices {
8662
8754
  private static var waiting: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
8663
8755
  /// Coordinator events a native screen hears too.
8664
- static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftMusicChange", "craftHeartRate"]
8756
+ static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftHealthHeartRate", "craftMusicChange", "craftHeartRate"]
8665
8757
  static let event = Notification.Name("craftWebServicesEvent")
8666
8758
 
8667
8759
  static var isAvailable: Bool { CraftSwiftShim.coordinator != nil }
@@ -13,18 +13,29 @@ struct CraftHealthError: Error {
13
13
  /// the hybrid's web view, CraftApp.swift) and native screens alike
14
14
  /// (`Health` through CraftNativeActions), so a screen drawn natively reads
15
15
  /// and writes Apple Health exactly as a page does. One HKHealthStore for the
16
- /// process, as Apple asks. Every answer arrives through `completion`, on
17
- /// whatever queue HealthKit answered on; each caller hops to its own.
16
+ /// process, as Apple asks. Every answer is delivered on the main queue so the
17
+ /// native-screen JavaScript bridge can safely resume its pending call.
18
18
  final class CraftHealth {
19
19
  typealias Completion = (Result<Any, CraftHealthError>) -> Void
20
20
 
21
21
  static let shared = CraftHealth()
22
22
 
23
+ private func completeOnMain(_ completion: @escaping Completion) -> Completion {
24
+ { result in
25
+ if Thread.isMainThread {
26
+ completion(result)
27
+ } else {
28
+ DispatchQueue.main.async { completion(result) }
29
+ }
30
+ }
31
+ }
32
+
23
33
  /// Nil where Apple Health is not on this device (an iPad without it).
24
34
  let healthStore: HKHealthStore? = HKHealthStore.isHealthDataAvailable() ? HKHealthStore() : nil
25
35
 
26
36
  /// A native screen's call (`Health` in CraftNativeActions), by method and arguments as the bridge sends them.
27
- func perform(method: String, args: [Any], completion: @escaping Completion) {
37
+ func perform(method: String, args: [Any], completion rawCompletion: @escaping Completion) {
38
+ let completion = completeOnMain(rawCompletion)
28
39
  let options = { (index: Int) in args.count > index ? args[index] as? [String: Any] ?? [:] : [:] }
29
40
  switch method {
30
41
  case "requestAuthorization":
@@ -32,6 +43,13 @@ final class CraftHealth {
32
43
  case "getData":
33
44
  guard let type = args.first as? String else { return completion(.failure(CraftHealthError(message: "getData needs a type", code: "INVALID_ARGUMENT"))) }
34
45
  getData(type: type, startDate: options(1)["startDate"] as? Double, endDate: options(1)["endDate"] as? Double, completion: completion)
46
+ case "saveData":
47
+ guard let type = args.first as? String,
48
+ args.count > 1,
49
+ let value = (args[1] as? NSNumber)?.doubleValue else {
50
+ return completion(.failure(CraftHealthError(message: "saveData needs a type and numeric value", code: "INVALID_ARGUMENT")))
51
+ }
52
+ saveData(type: type, value: value, options: options(2), completion: completion)
35
53
  case "saveWorkout":
36
54
  saveWorkout(body: options(0), completion: completion)
37
55
  case "getWorkouts":
@@ -47,12 +65,17 @@ final class CraftHealth {
47
65
  saveMindfulSession(body: options(0), completion: completion)
48
66
  case "getMindfulSessions":
49
67
  getMindfulSessions(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, limit: options(0)["limit"] as? Int, completion: completion)
68
+ case "startHeartRateUpdates":
69
+ startHeartRateUpdates(since: options(0)["since"] as? Double, completion: completion)
70
+ case "stopHeartRateUpdates":
71
+ stopHeartRateUpdates(completion: completion)
50
72
  default:
51
73
  completion(.failure(CraftHealthError(message: "Unsupported Health method", code: "UNKNOWN_ACTION")))
52
74
  }
53
75
  }
54
76
 
55
- func requestAuthorization(types: [String], readOnly: Bool = false, completion: @escaping Completion) {
77
+ func requestAuthorization(types: [String], readOnly: Bool = false, completion rawCompletion: @escaping Completion) {
78
+ let completion = completeOnMain(rawCompletion)
56
79
  guard let healthStore = healthStore else {
57
80
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
58
81
  return
@@ -120,12 +143,15 @@ final class CraftHealth {
120
143
  }
121
144
  case "dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat":
122
145
  // What a food log (MyFitnessPal, Lose It!, Cronometer...)
123
- // writes to Apple Health: read only, never written back.
146
+ // writes to Apple Health. Request the write grant too unless
147
+ // the caller explicitly asked for a read-only authorization.
124
148
  if let dietaryType = Self.healthQuantity(type)?.type {
125
149
  readTypes.insert(dietaryType)
150
+ if !readOnly { shareTypes.insert(dietaryType) }
126
151
  }
127
152
  default:
128
- break
153
+ completion(.failure(CraftHealthError(message: "Unknown health data type: \(type)", code: "INVALID_ARGUMENT")))
154
+ return
129
155
  }
130
156
  }
131
157
 
@@ -141,7 +167,8 @@ final class CraftHealth {
141
167
  }
142
168
  }
143
169
 
144
- func getData(type: String, startDate: Double?, endDate: Double?, completion: @escaping Completion) {
170
+ func getData(type: String, startDate: Double?, endDate: Double?, completion rawCompletion: @escaping Completion) {
171
+ let completion = completeOnMain(rawCompletion)
145
172
  guard let healthStore = healthStore else {
146
173
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
147
174
  return
@@ -179,7 +206,45 @@ final class CraftHealth {
179
206
  healthStore.execute(query)
180
207
  }
181
208
 
182
- func saveWorkout(body: [String: Any], completion: @escaping Completion) {
209
+ /// Save one quantity sample, primarily a food-log entry. Dates are epoch
210
+ /// milliseconds; an omitted range records an instantaneous sample now.
211
+ func saveData(type: String, value: Double, options: [String: Any], completion rawCompletion: @escaping Completion) {
212
+ let completion = completeOnMain(rawCompletion)
213
+ guard let healthStore = healthStore else {
214
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
215
+ return
216
+ }
217
+ guard value.isFinite, value >= 0 else {
218
+ completion(.failure(CraftHealthError(message: "saveData needs a finite, non-negative value", code: "INVALID_ARGUMENT")))
219
+ return
220
+ }
221
+ guard let spec = Self.healthQuantity(type), let qType = spec.type else {
222
+ completion(.failure(CraftHealthError(message: "Unknown health data type: \(type)", code: "INVALID_ARGUMENT")))
223
+ return
224
+ }
225
+ let startValue = options["startDate"] as? Double ?? Date().timeIntervalSince1970 * 1000
226
+ let endValue = options["endDate"] as? Double ?? startValue
227
+ guard startValue.isFinite, endValue.isFinite, endValue >= startValue else {
228
+ completion(.failure(CraftHealthError(message: "saveData needs a valid date range", code: "INVALID_ARGUMENT")))
229
+ return
230
+ }
231
+ let sample = HKQuantitySample(
232
+ type: qType,
233
+ quantity: HKQuantity(unit: spec.unit, doubleValue: value),
234
+ start: Date(timeIntervalSince1970: startValue / 1000),
235
+ end: Date(timeIntervalSince1970: endValue / 1000)
236
+ )
237
+ healthStore.save(sample) { success, error in
238
+ if success {
239
+ completion(.success(["id": sample.uuid.uuidString]))
240
+ } else {
241
+ completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Health data could not be saved")))
242
+ }
243
+ }
244
+ }
245
+
246
+ func saveWorkout(body: [String: Any], completion rawCompletion: @escaping Completion) {
247
+ let completion = completeOnMain(rawCompletion)
183
248
  guard let healthStore = healthStore else {
184
249
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
185
250
  return
@@ -332,7 +397,8 @@ final class CraftHealth {
332
397
 
333
398
  /// Workouts in Apple Health — the watch's, and every app's that
334
399
  /// writes there — newest first, with the numbers a training log needs.
335
- func getWorkouts(startDate: Double?, endDate: Double?, limit: Int?, completion: @escaping Completion) {
400
+ func getWorkouts(startDate: Double?, endDate: Double?, limit: Int?, completion rawCompletion: @escaping Completion) {
401
+ let completion = completeOnMain(rawCompletion)
336
402
  guard let healthStore = healthStore else {
337
403
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
338
404
  return
@@ -390,7 +456,8 @@ final class CraftHealth {
390
456
  /// One value per local day: a sum for steps, energy and distance, an
391
457
  /// average for heart rate, resting heart rate and HRV, the latest
392
458
  /// weight, and hours asleep for sleep (counted on the day you woke).
393
- func getDailyStatistics(type: String, startDate: Double?, endDate: Double?, completion: @escaping Completion) {
459
+ func getDailyStatistics(type: String, startDate: Double?, endDate: Double?, completion rawCompletion: @escaping Completion) {
460
+ let completion = completeOnMain(rawCompletion)
394
461
  guard let healthStore = healthStore else {
395
462
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
396
463
  return
@@ -464,7 +531,8 @@ final class CraftHealth {
464
531
  /// beats per minute, when it was taken, and the app and device that
465
532
  /// wrote it. A watch's workout readings can arrive as one series
466
533
  /// sample; those also carry their count, lowest and highest.
467
- func getHeartRateSamples(startDate: Double?, endDate: Double?, limit: Int?, completion: @escaping Completion) {
534
+ func getHeartRateSamples(startDate: Double?, endDate: Double?, limit: Int?, completion rawCompletion: @escaping Completion) {
535
+ let completion = completeOnMain(rawCompletion)
468
536
  guard let healthStore = healthStore else {
469
537
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
470
538
  return
@@ -480,39 +548,42 @@ final class CraftHealth {
480
548
  let oldestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
481
549
  let cap = max(1, min(limit ?? 10_000, 50_000))
482
550
 
483
- let query = HKSampleQuery(sampleType: heartType, predicate: predicate, limit: cap, sortDescriptors: [oldestFirst]) { [weak self] _, samples, error in
551
+ let query = HKSampleQuery(sampleType: heartType, predicate: predicate, limit: cap, sortDescriptors: [oldestFirst]) { _, samples, error in
484
552
  if let error = error, (error as? HKError)?.code != .errorNoData {
485
553
  completion(.failure(CraftHealthError(message: error.localizedDescription)))
486
554
  return
487
555
  }
556
+ completion(.success((samples as? [HKQuantitySample] ?? []).map(Self.heartRateRow)))
557
+ }
558
+ healthStore.execute(query)
559
+ }
488
560
 
489
- let bpm = HKUnit.count().unitDivided(by: .minute())
490
- let rows: [[String: Any]] = (samples as? [HKQuantitySample] ?? []).map { sample in
491
- var item: [String: Any] = [
492
- "bpm": sample.quantity.doubleValue(for: bpm),
493
- "startDate": sample.startDate.timeIntervalSince1970 * 1000,
494
- "endDate": sample.endDate.timeIntervalSince1970 * 1000,
495
- "sourceName": sample.sourceRevision.source.name,
496
- ]
497
- if let device = sample.device?.name { item["deviceName"] = device }
498
- if let model = sample.device?.model { item["deviceModel"] = model }
499
- if let series = sample as? HKDiscreteQuantitySample, series.count > 1 {
500
- item["count"] = series.count
501
- item["min"] = series.minimumQuantity.doubleValue(for: bpm)
502
- item["max"] = series.maximumQuantity.doubleValue(for: bpm)
503
- }
504
- if let raw = sample.metadata?[HKMetadataKeyHeartRateMotionContext] as? NSNumber {
505
- switch HKHeartRateMotionContext(rawValue: raw.intValue) {
506
- case .sedentary: item["motionContext"] = "sedentary"
507
- case .active: item["motionContext"] = "active"
508
- default: break
509
- }
510
- }
511
- return item
561
+ /// One heart-rate sample as a page sees it, from a read or the stream
562
+ /// alike: bpm, when, the app and device that wrote it, a series' count,
563
+ /// lowest and highest, and what the wearer was doing when the watch says.
564
+ private static func heartRateRow(_ sample: HKQuantitySample) -> [String: Any] {
565
+ let bpm = HKUnit.count().unitDivided(by: .minute())
566
+ var item: [String: Any] = [
567
+ "bpm": sample.quantity.doubleValue(for: bpm),
568
+ "startDate": sample.startDate.timeIntervalSince1970 * 1000,
569
+ "endDate": sample.endDate.timeIntervalSince1970 * 1000,
570
+ "sourceName": sample.sourceRevision.source.name,
571
+ ]
572
+ if let device = sample.device?.name { item["deviceName"] = device }
573
+ if let model = sample.device?.model { item["deviceModel"] = model }
574
+ if let series = sample as? HKDiscreteQuantitySample, series.count > 1 {
575
+ item["count"] = series.count
576
+ item["min"] = series.minimumQuantity.doubleValue(for: bpm)
577
+ item["max"] = series.maximumQuantity.doubleValue(for: bpm)
578
+ }
579
+ if let raw = sample.metadata?[HKMetadataKeyHeartRateMotionContext] as? NSNumber {
580
+ switch HKHeartRateMotionContext(rawValue: raw.intValue) {
581
+ case .sedentary: item["motionContext"] = "sedentary"
582
+ case .active: item["motionContext"] = "active"
583
+ default: break
512
584
  }
513
- completion(.success(rows))
514
585
  }
515
- healthStore.execute(query)
586
+ return item
516
587
  }
517
588
 
518
589
  /// A stable name for each sleep analysis value: in bed, awake, or one
@@ -531,7 +602,8 @@ final class CraftHealth {
531
602
  /// Sleep between two instants, oldest first, as the sources wrote it:
532
603
  /// time in bed and every asleep or awake stretch, each with its stage
533
604
  /// and whether it counts as asleep, so a page can mark the night.
534
- func getSleepIntervals(startDate: Double?, endDate: Double?, completion: @escaping Completion) {
605
+ func getSleepIntervals(startDate: Double?, endDate: Double?, completion rawCompletion: @escaping Completion) {
606
+ let completion = completeOnMain(rawCompletion)
535
607
  guard let healthStore = healthStore else {
536
608
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
537
609
  return
@@ -577,7 +649,8 @@ final class CraftHealth {
577
649
  /// as the sample's external UUID, so saving the same session twice (a
578
650
  /// retry, a second device) keeps the first sample instead of adding
579
651
  /// another: the answer then says `existing: true`.
580
- func saveMindfulSession(body: [String: Any], completion: @escaping Completion) {
652
+ func saveMindfulSession(body: [String: Any], completion rawCompletion: @escaping Completion) {
653
+ let completion = completeOnMain(rawCompletion)
581
654
  guard let healthStore = healthStore else {
582
655
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
583
656
  return
@@ -629,7 +702,8 @@ final class CraftHealth {
629
702
  /// that writes them (the Mindfulness app, Calm, this one). Each says
630
703
  /// whether this app wrote it (`ownApp`) and carries the external id it
631
704
  /// was saved with, so a page can tell its own sessions from the rest.
632
- func getMindfulSessions(startDate: Double?, endDate: Double?, limit: Int?, completion: @escaping Completion) {
705
+ func getMindfulSessions(startDate: Double?, endDate: Double?, limit: Int?, completion rawCompletion: @escaping Completion) {
706
+ let completion = completeOnMain(rawCompletion)
633
707
  guard let healthStore = healthStore else {
634
708
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
635
709
  return
@@ -671,4 +745,151 @@ final class CraftHealth {
671
745
  healthStore.execute(query)
672
746
  }
673
747
 
748
+ // MARK: Heart rate as it arrives
749
+
750
+ /// The web view's coordinator, which sends `craftHealthHeartRate` to the
751
+ /// page and every native screen. False (or unset, in an app without a web
752
+ /// view) and the event goes to the native screens alone.
753
+ var pageSink: (([String: Any]) -> Bool)?
754
+
755
+ /// The one running stream: an anchored query that hears every heart-rate
756
+ /// sample HealthKit receives after it starts.
757
+ private var heartRateQuery: HKAnchoredObjectQuery?
758
+ /// Samples already sent, so a batch HealthKit repeats is not sent twice.
759
+ private var heartRateSent: Set<UUID> = []
760
+ /// Bumped by every start and stop, so a stopped stream's late batch is dropped.
761
+ private var heartRateStream = 0
762
+ #if targetEnvironment(simulator)
763
+ private var simulatedHeartRateTimer: Timer?
764
+ #endif
765
+
766
+ private func onMain(_ work: @escaping () -> Void) {
767
+ if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
768
+ }
769
+
770
+ /// Heart rate as Apple Health receives it, for as long as it runs: every
771
+ /// sample that starts at or after `since` (epoch ms; two minutes ago by
772
+ /// default), first what is there already, then each batch as the watch
773
+ /// syncs it, as `craftHealthHeartRate` events (`{samples}`, oldest first,
774
+ /// each sample once, in getHeartRateSamples' shape). One stream at a
775
+ /// time: starting again replaces the running one. Reads under the
776
+ /// `heartRate` grant. Answers true once the samples already there are
777
+ /// sent.
778
+ ///
779
+ /// Simulator builds with the `CraftFakeHealthHeartRate` default
780
+ /// (`xcrun simctl spawn booted defaults write <bundle id>
781
+ /// CraftFakeHealthHeartRate -bool YES`) skip HealthKit and send a made-up
782
+ /// watch reading every five seconds instead.
783
+ func startHeartRateUpdates(since: Double?, completion rawCompletion: @escaping Completion) {
784
+ let completion = completeOnMain(rawCompletion)
785
+ let start = Date(timeIntervalSince1970: (since ?? Date().timeIntervalSince1970 * 1000 - 120_000) / 1000)
786
+ onMain { [self] in
787
+ stopHeartRateStream()
788
+ let stream = heartRateStream
789
+
790
+ #if targetEnvironment(simulator)
791
+ if UserDefaults.standard.bool(forKey: "CraftFakeHealthHeartRate") {
792
+ startSimulatedHeartRate(stream: stream)
793
+ completion(.success(true))
794
+ return
795
+ }
796
+ #endif
797
+
798
+ guard let healthStore = healthStore else {
799
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
800
+ return
801
+ }
802
+ guard let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) else {
803
+ completion(.failure(CraftHealthError(message: "Heart rate is unavailable")))
804
+ return
805
+ }
806
+
807
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: nil, options: .strictStartDate)
808
+ let query = HKAnchoredObjectQuery(type: heartType, predicate: predicate, anchor: nil, limit: HKObjectQueryNoLimit) { [weak self] _, samples, _, _, error in
809
+ let rows = (samples as? [HKQuantitySample] ?? []).map { ($0.uuid, Self.heartRateRow($0)) }
810
+ DispatchQueue.main.async {
811
+ // Stopped or replaced before its first batch: it did start.
812
+ guard let self, self.heartRateStream == stream else { return completion(.success(true)) }
813
+ if let error = error, (error as? HKError)?.code != .errorNoData {
814
+ self.stopHeartRateStream()
815
+ return completion(.failure(CraftHealthError(message: error.localizedDescription)))
816
+ }
817
+ self.sendHeartRate(rows)
818
+ completion(.success(true))
819
+ }
820
+ }
821
+ query.updateHandler = { [weak self] _, samples, _, _, error in
822
+ if let error = error {
823
+ NSLog("[craft] Apple Health heart rate: %@", error.localizedDescription)
824
+ return
825
+ }
826
+ let rows = (samples as? [HKQuantitySample] ?? []).map { ($0.uuid, Self.heartRateRow($0)) }
827
+ DispatchQueue.main.async {
828
+ guard let self, self.heartRateStream == stream else { return }
829
+ self.sendHeartRate(rows)
830
+ }
831
+ }
832
+ heartRateQuery = query
833
+ healthStore.execute(query)
834
+ }
835
+ }
836
+
837
+ /// Stops the stream; true whether or not one was running.
838
+ func stopHeartRateUpdates(completion rawCompletion: @escaping Completion) {
839
+ let completion = completeOnMain(rawCompletion)
840
+ onMain { [self] in
841
+ stopHeartRateStream()
842
+ completion(.success(true))
843
+ }
844
+ }
845
+
846
+ /// Main only.
847
+ private func stopHeartRateStream() {
848
+ heartRateStream += 1
849
+ if let query = heartRateQuery { healthStore?.stop(query) }
850
+ heartRateQuery = nil
851
+ heartRateSent.removeAll()
852
+ #if targetEnvironment(simulator)
853
+ simulatedHeartRateTimer?.invalidate()
854
+ simulatedHeartRateTimer = nil
855
+ #endif
856
+ }
857
+
858
+ /// Sends the samples not sent before, oldest first; nothing when none are new. Main only.
859
+ private func sendHeartRate(_ rows: [(UUID, [String: Any])]) {
860
+ let fresh = rows
861
+ .filter { heartRateSent.insert($0.0).inserted }
862
+ .map { $0.1 }
863
+ .sorted { ($0["startDate"] as? Double ?? 0) < ($1["startDate"] as? Double ?? 0) }
864
+ guard !fresh.isEmpty else { return }
865
+ let detail: [String: Any] = ["samples": fresh]
866
+ if pageSink?(detail) != true { CraftWebServices.forward("craftHealthHeartRate", data: detail) }
867
+ }
868
+
869
+ /// The simulator's watch: someone settling into slow breathing, about 72
870
+ /// at first and easing toward 60 over a few minutes, with a small wobble.
871
+ static func simulatedHeartRate(elapsed: TimeInterval) -> Double {
872
+ (60 + 12 * exp(-elapsed / 150) + 1.5 * sin(elapsed / 9)).rounded()
873
+ }
874
+
875
+ #if targetEnvironment(simulator)
876
+ /// One made-up watch reading now and every five seconds after. Main only.
877
+ private func startSimulatedHeartRate(stream: Int) {
878
+ let began = Date()
879
+ let send: () -> Void = { [weak self] in
880
+ guard let self, self.heartRateStream == stream else { return }
881
+ let now = Date().timeIntervalSince1970 * 1000
882
+ self.sendHeartRate([(UUID(), [
883
+ "bpm": Self.simulatedHeartRate(elapsed: Date().timeIntervalSince(began)),
884
+ "startDate": now,
885
+ "endDate": now,
886
+ "sourceName": "Apple Watch (simulated)",
887
+ "deviceName": "Apple Watch",
888
+ "motionContext": "sedentary",
889
+ ])])
890
+ }
891
+ send()
892
+ simulatedHeartRateTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in send() }
893
+ }
894
+ #endif
674
895
  }
@@ -115,7 +115,8 @@ struct CraftHeartRateMeasurement: Equatable {
115
115
  /// Simulator builds have no Bluetooth; with the `CraftFakeHeartRateMonitor`
116
116
  /// default set (`xcrun simctl spawn booted defaults write <bundle id>
117
117
  /// CraftFakeHeartRateMonitor -bool YES`) a made-up strap stands in, whose
118
- /// readings are encoded as 0x2A37 payloads and decoded like a real one's.
118
+ /// readings are encoded as 0x2A37 payloads and decoded like a real one's. Its
119
+ /// RR intervals vary beat to beat with the breath, as a heart's do.
119
120
  ///
120
121
  /// Everything runs on main: the central manager delivers there, and every
121
122
  /// caller hops there first.
@@ -176,6 +177,9 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
176
177
  private static let simulatedId = UUID(uuidString: "00000000-0000-4000-8000-00000000180D")!
177
178
  private var simulatedTimer: Timer?
178
179
  private var simulatedBeat = 0
180
+ /// Milliseconds since the stand-in connected, and how far its beats reach.
181
+ private var simulatedElapsed: Double = 0
182
+ private var simulatedBeatsUntil: Double = 0
179
183
 
180
184
  // MARK: Entry
181
185
 
@@ -566,16 +570,42 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
566
570
  emitState()
567
571
  settleConnect(.success(state()))
568
572
  simulatedTimer?.invalidate()
573
+ simulatedElapsed = 0
574
+ simulatedBeatsUntil = 0
569
575
  simulatedTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
570
576
  guard let self else { return }
571
577
  self.simulatedBeat += 1
578
+ self.simulatedElapsed += 1000
572
579
  // A steady run with a slow swell, the way a real one drifts.
573
- let bpm = 142 + Int((sin(Double(self.simulatedBeat) / 20) * 12).rounded())
574
- let interval = 60_000 / Double(bpm)
575
- self.received(CraftHeartRateMeasurement.encode(bpm: bpm, sensorContact: true, rrIntervals: [interval]))
580
+ let rate = 142 + sin(Double(self.simulatedBeat) / 20) * 12
581
+ var beatsUntil = self.simulatedBeatsUntil
582
+ let intervals = Self.simulatedIntervals(rate: rate, from: &beatsUntil, until: self.simulatedElapsed)
583
+ self.simulatedBeatsUntil = beatsUntil
584
+ guard !intervals.isEmpty else { return }
585
+ // The rate a strap reports is the one its intervals give.
586
+ let bpm = Int((60_000 / (intervals.reduce(0, +) / Double(intervals.count))).rounded())
587
+ self.received(CraftHeartRateMeasurement.encode(bpm: bpm, sensorContact: true, rrIntervals: intervals))
576
588
  }
577
589
  }
578
590
 
591
+ /// The beats from `start` (ms) until `end`, each interval in ms: the
592
+ /// rate's own interval, plus respiratory sinus arrhythmia (beats come
593
+ /// closer on the in-breath and spread on the out-breath over a ten-second
594
+ /// breath, ±4% of the interval: ±40 ms at 60 bpm) and a few ms of noise,
595
+ /// so a heart-rate variability worked out from them means something.
596
+ /// `start` moves to the last beat.
597
+ static func simulatedIntervals(rate: Double, from start: inout Double, until end: Double, noise: () -> Double = { Double.random(in: -4...4) }) -> [Double] {
598
+ let base = 60_000 / rate
599
+ var intervals: [Double] = []
600
+ while start < end {
601
+ let breath = sin(2 * Double.pi * start / 10_000)
602
+ let interval = base * (1 - 0.04 * breath) + noise()
603
+ start += interval
604
+ intervals.append(interval)
605
+ }
606
+ return intervals
607
+ }
608
+
579
609
  // MARK: Recording
580
610
 
581
611
  private var directory: URL {