craft-native 0.0.164 → 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":
@@ -750,6 +756,29 @@ final class CraftChrome: ObservableObject {
750
756
  emit("craftTabSelect", ["id": id])
751
757
  }
752
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
+
753
782
  /// The animation, or none with Reduce Motion on: the selection jumps to
754
783
  /// its tab rather than springing there, as UIKit's own controls do.
755
784
  static func motion(_ animation: Animation) -> Animation? {
@@ -1752,6 +1781,17 @@ struct CraftWebView: UIViewRepresentable {
1752
1781
  if config.enableMotionSensors {
1753
1782
  motionManager = CMMotionManager()
1754
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
+ }
1755
1795
  if config.enableHeartRate {
1756
1796
  // The strap's readings and connection changes reach the page,
1757
1797
  // and through sendToWeb every native screen too. False once
@@ -2610,6 +2650,20 @@ struct CraftWebView: UIViewRepresentable {
2610
2650
  } else {
2611
2651
  rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2612
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
+ }
2613
2667
 
2614
2668
  // MARK: - Live Activities
2615
2669
  case "startLiveActivity":
@@ -5132,7 +5186,14 @@ struct CraftWebView: UIViewRepresentable {
5132
5186
  getMindfulSessions: function(options) {
5133
5187
  options = options || {};
5134
5188
  return craft._invoke('getHealthMindfulSessions', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
5135
- }
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'); }
5136
5197
  };
5137
5198
  // The Music app's song and transport. Nothing is read until
5138
5199
  // requestAuthorization() has been allowed; observe(true) makes
@@ -8692,7 +8753,7 @@ final class CraftZigRuntime: NSObject {
8692
8753
  enum CraftWebServices {
8693
8754
  private static var waiting: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
8694
8755
  /// Coordinator events a native screen hears too.
8695
- static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftMusicChange", "craftHeartRate"]
8756
+ static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftHealthHeartRate", "craftMusicChange", "craftHeartRate"]
8696
8757
  static let event = Notification.Name("craftWebServicesEvent")
8697
8758
 
8698
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":
@@ -54,12 +65,17 @@ final class CraftHealth {
54
65
  saveMindfulSession(body: options(0), completion: completion)
55
66
  case "getMindfulSessions":
56
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)
57
72
  default:
58
73
  completion(.failure(CraftHealthError(message: "Unsupported Health method", code: "UNKNOWN_ACTION")))
59
74
  }
60
75
  }
61
76
 
62
- 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)
63
79
  guard let healthStore = healthStore else {
64
80
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
65
81
  return
@@ -151,7 +167,8 @@ final class CraftHealth {
151
167
  }
152
168
  }
153
169
 
154
- 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)
155
172
  guard let healthStore = healthStore else {
156
173
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
157
174
  return
@@ -191,7 +208,8 @@ final class CraftHealth {
191
208
 
192
209
  /// Save one quantity sample, primarily a food-log entry. Dates are epoch
193
210
  /// milliseconds; an omitted range records an instantaneous sample now.
194
- func saveData(type: String, value: Double, options: [String: Any], completion: @escaping Completion) {
211
+ func saveData(type: String, value: Double, options: [String: Any], completion rawCompletion: @escaping Completion) {
212
+ let completion = completeOnMain(rawCompletion)
195
213
  guard let healthStore = healthStore else {
196
214
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
197
215
  return
@@ -225,7 +243,8 @@ final class CraftHealth {
225
243
  }
226
244
  }
227
245
 
228
- func saveWorkout(body: [String: Any], completion: @escaping Completion) {
246
+ func saveWorkout(body: [String: Any], completion rawCompletion: @escaping Completion) {
247
+ let completion = completeOnMain(rawCompletion)
229
248
  guard let healthStore = healthStore else {
230
249
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
231
250
  return
@@ -378,7 +397,8 @@ final class CraftHealth {
378
397
 
379
398
  /// Workouts in Apple Health — the watch's, and every app's that
380
399
  /// writes there — newest first, with the numbers a training log needs.
381
- 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)
382
402
  guard let healthStore = healthStore else {
383
403
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
384
404
  return
@@ -436,7 +456,8 @@ final class CraftHealth {
436
456
  /// One value per local day: a sum for steps, energy and distance, an
437
457
  /// average for heart rate, resting heart rate and HRV, the latest
438
458
  /// weight, and hours asleep for sleep (counted on the day you woke).
439
- 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)
440
461
  guard let healthStore = healthStore else {
441
462
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
442
463
  return
@@ -510,7 +531,8 @@ final class CraftHealth {
510
531
  /// beats per minute, when it was taken, and the app and device that
511
532
  /// wrote it. A watch's workout readings can arrive as one series
512
533
  /// sample; those also carry their count, lowest and highest.
513
- 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)
514
536
  guard let healthStore = healthStore else {
515
537
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
516
538
  return
@@ -526,39 +548,42 @@ final class CraftHealth {
526
548
  let oldestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
527
549
  let cap = max(1, min(limit ?? 10_000, 50_000))
528
550
 
529
- 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
530
552
  if let error = error, (error as? HKError)?.code != .errorNoData {
531
553
  completion(.failure(CraftHealthError(message: error.localizedDescription)))
532
554
  return
533
555
  }
556
+ completion(.success((samples as? [HKQuantitySample] ?? []).map(Self.heartRateRow)))
557
+ }
558
+ healthStore.execute(query)
559
+ }
534
560
 
535
- let bpm = HKUnit.count().unitDivided(by: .minute())
536
- let rows: [[String: Any]] = (samples as? [HKQuantitySample] ?? []).map { sample in
537
- var item: [String: Any] = [
538
- "bpm": sample.quantity.doubleValue(for: bpm),
539
- "startDate": sample.startDate.timeIntervalSince1970 * 1000,
540
- "endDate": sample.endDate.timeIntervalSince1970 * 1000,
541
- "sourceName": sample.sourceRevision.source.name,
542
- ]
543
- if let device = sample.device?.name { item["deviceName"] = device }
544
- if let model = sample.device?.model { item["deviceModel"] = model }
545
- if let series = sample as? HKDiscreteQuantitySample, series.count > 1 {
546
- item["count"] = series.count
547
- item["min"] = series.minimumQuantity.doubleValue(for: bpm)
548
- item["max"] = series.maximumQuantity.doubleValue(for: bpm)
549
- }
550
- if let raw = sample.metadata?[HKMetadataKeyHeartRateMotionContext] as? NSNumber {
551
- switch HKHeartRateMotionContext(rawValue: raw.intValue) {
552
- case .sedentary: item["motionContext"] = "sedentary"
553
- case .active: item["motionContext"] = "active"
554
- default: break
555
- }
556
- }
557
- 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
558
584
  }
559
- completion(.success(rows))
560
585
  }
561
- healthStore.execute(query)
586
+ return item
562
587
  }
563
588
 
564
589
  /// A stable name for each sleep analysis value: in bed, awake, or one
@@ -577,7 +602,8 @@ final class CraftHealth {
577
602
  /// Sleep between two instants, oldest first, as the sources wrote it:
578
603
  /// time in bed and every asleep or awake stretch, each with its stage
579
604
  /// and whether it counts as asleep, so a page can mark the night.
580
- 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)
581
607
  guard let healthStore = healthStore else {
582
608
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
583
609
  return
@@ -623,7 +649,8 @@ final class CraftHealth {
623
649
  /// as the sample's external UUID, so saving the same session twice (a
624
650
  /// retry, a second device) keeps the first sample instead of adding
625
651
  /// another: the answer then says `existing: true`.
626
- func saveMindfulSession(body: [String: Any], completion: @escaping Completion) {
652
+ func saveMindfulSession(body: [String: Any], completion rawCompletion: @escaping Completion) {
653
+ let completion = completeOnMain(rawCompletion)
627
654
  guard let healthStore = healthStore else {
628
655
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
629
656
  return
@@ -675,7 +702,8 @@ final class CraftHealth {
675
702
  /// that writes them (the Mindfulness app, Calm, this one). Each says
676
703
  /// whether this app wrote it (`ownApp`) and carries the external id it
677
704
  /// was saved with, so a page can tell its own sessions from the rest.
678
- 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)
679
707
  guard let healthStore = healthStore else {
680
708
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
681
709
  return
@@ -717,4 +745,151 @@ final class CraftHealth {
717
745
  healthStore.execute(query)
718
746
  }
719
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
720
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 {