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.
- package/dist/android/templates/AndroidManifest.xml.template +1 -1
- package/dist/android/templates/MainActivityNative.kt.template +516 -119
- package/dist/api/mobile.d.ts +34 -2
- 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 +94 -2
- package/dist/ios/templates/CraftHealth.swift +261 -40
- package/dist/ios/templates/CraftHeartRate.swift +34 -4
- package/dist/ios/templates/CraftHybrid.swift +174 -2
- package/dist/ios/templates/CraftNativeActions.swift +111 -1
- package/dist/ios/templates/CraftNativeFiles.swift +107 -0
- package/dist/ios/templates/CraftNativeScreen.swift +354 -59
- package/dist/mobile.js +35 -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":
|
|
@@ -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
|
|
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":
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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]) {
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|