craft-native 0.0.164 → 0.0.167
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/templates/AndroidManifest.xml.template +1 -1
- package/dist/android/templates/MainActivityNative.kt.template +416 -36
- package/dist/api/mobile.d.ts +25 -1
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +2 -2
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +1 -0
- package/dist/ios/src/index.js +138 -88
- package/dist/ios/src/source-audit.d.ts +8 -0
- package/dist/ios/templates/CraftApp.swift +102 -13
- package/dist/ios/templates/CraftHealth.swift +214 -39
- package/dist/ios/templates/CraftHeartRate.swift +34 -4
- package/dist/ios/templates/CraftHybrid.swift +140 -17
- package/dist/ios/templates/CraftNativeScreen.swift +338 -61
- package/dist/ios/templates/CraftNativeServices.swift +76 -23
- package/dist/mobile.js +29 -0
- package/package.json +1 -1
|
@@ -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? {
|
|
@@ -1683,14 +1712,22 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1683
1712
|
// Keep awake
|
|
1684
1713
|
private var isKeepingAwake = false
|
|
1685
1714
|
|
|
1686
|
-
// Speech synthesis.
|
|
1687
|
-
//
|
|
1688
|
-
//
|
|
1715
|
+
// Speech synthesis. Each utterance not finished yet is held beside the
|
|
1716
|
+
// call waiting on it. A cue that interrupts one still sounding speaks
|
|
1717
|
+
// on a fresh synthesizer: AVSpeechSynthesizer can drop an utterance
|
|
1718
|
+
// handed to it straight after `stopSpeaking(at: .immediate)`, which
|
|
1719
|
+
// lost the "2" of a "3, 2, 1" countdown. The stopped one is kept in
|
|
1720
|
+
// `retiredSpeechSynthesizers` until it reports the cancel.
|
|
1689
1721
|
private lazy var speechSynthesizer: AVSpeechSynthesizer = {
|
|
1690
1722
|
let synthesizer = AVSpeechSynthesizer()
|
|
1691
1723
|
synthesizer.delegate = self
|
|
1692
1724
|
return synthesizer
|
|
1693
1725
|
}()
|
|
1726
|
+
private var retiredSpeechSynthesizers: [AVSpeechSynthesizer] = []
|
|
1727
|
+
/// The session is handed back this long after the last word, so a
|
|
1728
|
+
/// countdown ducks the music once rather than between every number.
|
|
1729
|
+
private static let speechReleaseAfterSilence: TimeInterval = 1.5
|
|
1730
|
+
private var pendingSpeechRelease: DispatchWorkItem?
|
|
1694
1731
|
private var pendingUtterances: [(utterance: AVSpeechUtterance, callbackId: String?)] = []
|
|
1695
1732
|
/// What the session was before speech claimed it; nil while speech
|
|
1696
1733
|
/// does not hold it.
|
|
@@ -1752,6 +1789,17 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1752
1789
|
if config.enableMotionSensors {
|
|
1753
1790
|
motionManager = CMMotionManager()
|
|
1754
1791
|
}
|
|
1792
|
+
if config.enableHealthKit {
|
|
1793
|
+
// Apple Health's heart rate as it arrives
|
|
1794
|
+
// (craft.health.startHeartRateUpdates) reaches the page, and
|
|
1795
|
+
// through sendToWeb every native screen too. False once this
|
|
1796
|
+
// coordinator is gone: native screens still hear it.
|
|
1797
|
+
CraftHealth.shared.pageSink = { [weak self] detail in
|
|
1798
|
+
guard let self else { return false }
|
|
1799
|
+
self.sendToWeb("craftHealthHeartRate", data: detail)
|
|
1800
|
+
return true
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1755
1803
|
if config.enableHeartRate {
|
|
1756
1804
|
// The strap's readings and connection changes reach the page,
|
|
1757
1805
|
// and through sendToWeb every native screen too. False once
|
|
@@ -2610,6 +2658,20 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2610
2658
|
} else {
|
|
2611
2659
|
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
2612
2660
|
}
|
|
2661
|
+
// Heart rate as Apple Health receives it: craftHealthHeartRate
|
|
2662
|
+
// events until stopped (CraftHealth.swift).
|
|
2663
|
+
case "startHealthHeartRateUpdates":
|
|
2664
|
+
if config.enableHealthKit {
|
|
2665
|
+
CraftHealth.shared.startHeartRateUpdates(since: body["since"] as? Double) { [weak self] in self?.answer(callbackId, $0) }
|
|
2666
|
+
} else {
|
|
2667
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
2668
|
+
}
|
|
2669
|
+
case "stopHealthHeartRateUpdates":
|
|
2670
|
+
if config.enableHealthKit {
|
|
2671
|
+
CraftHealth.shared.stopHeartRateUpdates { [weak self] in self?.answer(callbackId, $0) }
|
|
2672
|
+
} else {
|
|
2673
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
2674
|
+
}
|
|
2613
2675
|
|
|
2614
2676
|
// MARK: - Live Activities
|
|
2615
2677
|
case "startLiveActivity":
|
|
@@ -5132,7 +5194,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5132
5194
|
getMindfulSessions: function(options) {
|
|
5133
5195
|
options = options || {};
|
|
5134
5196
|
return craft._invoke('getHealthMindfulSessions', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
|
|
5135
|
-
}
|
|
5197
|
+
},
|
|
5198
|
+
// Heart rate as Apple Health receives it, as
|
|
5199
|
+
// craftHealthHeartRate events ({samples}) until stopped.
|
|
5200
|
+
startHeartRateUpdates: function(options) {
|
|
5201
|
+
var since = options && typeof options.since === 'number' ? options.since : null;
|
|
5202
|
+
return craft._invoke('startHealthHeartRateUpdates', {since: since});
|
|
5203
|
+
},
|
|
5204
|
+
stopHeartRateUpdates: function() { return craft._invoke('stopHealthHeartRateUpdates'); }
|
|
5136
5205
|
};
|
|
5137
5206
|
// The Music app's song and transport. Nothing is read until
|
|
5138
5207
|
// requestAuthorization() has been allowed; observe(true) makes
|
|
@@ -6654,8 +6723,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6654
6723
|
|
|
6655
6724
|
private func speak(_ text: String, body: [String: Any], callbackId: String?) {
|
|
6656
6725
|
DispatchQueue.main.async {
|
|
6657
|
-
|
|
6658
|
-
|
|
6726
|
+
self.pendingSpeechRelease?.cancel()
|
|
6727
|
+
self.pendingSpeechRelease = nil
|
|
6728
|
+
if body["interrupt"] as? Bool ?? true, self.cancelPendingSpeech() {
|
|
6729
|
+
self.retiredSpeechSynthesizers.append(self.speechSynthesizer)
|
|
6730
|
+
let fresh = AVSpeechSynthesizer()
|
|
6731
|
+
fresh.delegate = self
|
|
6732
|
+
self.speechSynthesizer = fresh
|
|
6659
6733
|
}
|
|
6660
6734
|
|
|
6661
6735
|
let utterance = AVSpeechUtterance(string: text)
|
|
@@ -6757,8 +6831,20 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6757
6831
|
/// Checked on the main thread, where the queue of utterances lives, and
|
|
6758
6832
|
/// done on the speech queue after any setup already asked for, so a
|
|
6759
6833
|
/// cue that arrives meanwhile reclaims the session after this.
|
|
6760
|
-
private func releaseAudioSessionIfSilent(retry: Bool = true) {
|
|
6834
|
+
private func releaseAudioSessionIfSilent(retry: Bool = true, after delay: TimeInterval = Coordinator.speechReleaseAfterSilence) {
|
|
6761
6835
|
guard pendingUtterances.isEmpty else { return }
|
|
6836
|
+
pendingSpeechRelease?.cancel()
|
|
6837
|
+
let work = DispatchWorkItem { [weak self] in
|
|
6838
|
+
guard let self else { return }
|
|
6839
|
+
self.pendingSpeechRelease = nil
|
|
6840
|
+
guard self.pendingUtterances.isEmpty, !self.speechSynthesizer.isSpeaking else { return }
|
|
6841
|
+
self.deactivateSpeechAudioSession(retry: retry)
|
|
6842
|
+
}
|
|
6843
|
+
pendingSpeechRelease = work
|
|
6844
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
|
6845
|
+
}
|
|
6846
|
+
|
|
6847
|
+
private func deactivateSpeechAudioSession(retry: Bool) {
|
|
6762
6848
|
speechAudioQueue.async {
|
|
6763
6849
|
guard let previous = self.speechAudioSessionToRestore else { return }
|
|
6764
6850
|
let session = AVAudioSession.sharedInstance()
|
|
@@ -6771,8 +6857,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6771
6857
|
// right, and the ducking still ends when the music app
|
|
6772
6858
|
// hears nothing more.
|
|
6773
6859
|
if retry {
|
|
6774
|
-
DispatchQueue.main.
|
|
6775
|
-
self.releaseAudioSessionIfSilent(retry: false)
|
|
6860
|
+
DispatchQueue.main.async {
|
|
6861
|
+
self.releaseAudioSessionIfSilent(retry: false, after: 0.5)
|
|
6776
6862
|
}
|
|
6777
6863
|
return
|
|
6778
6864
|
}
|
|
@@ -6783,11 +6869,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6783
6869
|
}
|
|
6784
6870
|
}
|
|
6785
6871
|
|
|
6786
|
-
fileprivate func speechDidEnd(_ utterance: AVSpeechUtterance, spoken: Bool) {
|
|
6872
|
+
fileprivate func speechDidEnd(_ synthesizer: AVSpeechSynthesizer, _ utterance: AVSpeechUtterance, spoken: Bool) {
|
|
6787
6873
|
if let index = pendingUtterances.firstIndex(where: { $0.utterance === utterance }) {
|
|
6788
6874
|
let entry = pendingUtterances.remove(at: index)
|
|
6789
6875
|
resolveCallback(entry.callbackId, result: spoken)
|
|
6790
6876
|
}
|
|
6877
|
+
if synthesizer !== speechSynthesizer, !synthesizer.isSpeaking {
|
|
6878
|
+
retiredSpeechSynthesizers.removeAll { $0 === synthesizer }
|
|
6879
|
+
}
|
|
6791
6880
|
releaseAudioSessionIfSilent()
|
|
6792
6881
|
}
|
|
6793
6882
|
|
|
@@ -8419,11 +8508,11 @@ extension CraftWebView.Coordinator: WKUIDelegate {
|
|
|
8419
8508
|
|
|
8420
8509
|
extension CraftWebView.Coordinator: AVSpeechSynthesizerDelegate {
|
|
8421
8510
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
|
8422
|
-
DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: true) }
|
|
8511
|
+
DispatchQueue.main.async { self.speechDidEnd(synthesizer, utterance, spoken: true) }
|
|
8423
8512
|
}
|
|
8424
8513
|
|
|
8425
8514
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
|
8426
|
-
DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: false) }
|
|
8515
|
+
DispatchQueue.main.async { self.speechDidEnd(synthesizer, utterance, spoken: false) }
|
|
8427
8516
|
}
|
|
8428
8517
|
}
|
|
8429
8518
|
|
|
@@ -8692,7 +8781,7 @@ final class CraftZigRuntime: NSObject {
|
|
|
8692
8781
|
enum CraftWebServices {
|
|
8693
8782
|
private static var waiting: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
|
|
8694
8783
|
/// Coordinator events a native screen hears too.
|
|
8695
|
-
static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftMusicChange", "craftHeartRate"]
|
|
8784
|
+
static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftHealthHeartRate", "craftMusicChange", "craftHeartRate"]
|
|
8696
8785
|
static let event = Notification.Name("craftWebServicesEvent")
|
|
8697
8786
|
|
|
8698
8787
|
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
|
|
17
|
-
///
|
|
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]) {
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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
|
|
574
|
-
|
|
575
|
-
|
|
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 {
|