craft-native 0.0.140 → 0.0.141

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.
@@ -0,0 +1,551 @@
1
+ import CoreLocation
2
+ import Foundation
3
+ import HealthKit
4
+
5
+ /// A HealthKit read or write that did not happen: the message a page sees,
6
+ /// and the bridge's error code.
7
+ struct CraftHealthError: Error {
8
+ let message: String
9
+ var code: String = "CRAFT_ERROR"
10
+ }
11
+
12
+ /// Apple Health for the whole app: the web page's bridge (`craft.health` in
13
+ /// the hybrid's web view, CraftApp.swift) and native screens alike
14
+ /// (`Health` through CraftNativeActions), so a screen drawn natively reads
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.
18
+ final class CraftHealth {
19
+ typealias Completion = (Result<Any, CraftHealthError>) -> Void
20
+
21
+ static let shared = CraftHealth()
22
+
23
+ /// Nil where Apple Health is not on this device (an iPad without it).
24
+ let healthStore: HKHealthStore? = HKHealthStore.isHealthDataAvailable() ? HKHealthStore() : nil
25
+
26
+ /// 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) {
28
+ let options = { (index: Int) in args.count > index ? args[index] as? [String: Any] ?? [:] : [:] }
29
+ switch method {
30
+ case "requestAuthorization":
31
+ requestAuthorization(types: args.first as? [String] ?? [], readOnly: options(1)["write"] as? Bool == false || options(1)["readOnly"] as? Bool == true, completion: completion)
32
+ case "getData":
33
+ guard let type = args.first as? String else { return completion(.failure(CraftHealthError(message: "getData needs a type", code: "INVALID_ARGUMENT"))) }
34
+ getData(type: type, startDate: options(1)["startDate"] as? Double, endDate: options(1)["endDate"] as? Double, completion: completion)
35
+ case "saveWorkout":
36
+ saveWorkout(body: options(0), completion: completion)
37
+ case "getWorkouts":
38
+ getWorkouts(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, limit: options(0)["limit"] as? Int, completion: completion)
39
+ case "getDailyStatistics":
40
+ guard let type = args.first as? String else { return completion(.failure(CraftHealthError(message: "getDailyStatistics needs a type", code: "INVALID_ARGUMENT"))) }
41
+ getDailyStatistics(type: type, startDate: options(1)["startDate"] as? Double, endDate: options(1)["endDate"] as? Double, completion: completion)
42
+ case "getHeartRateSamples":
43
+ getHeartRateSamples(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, limit: options(0)["limit"] as? Int, completion: completion)
44
+ case "getSleepIntervals":
45
+ getSleepIntervals(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, completion: completion)
46
+ default:
47
+ completion(.failure(CraftHealthError(message: "Unsupported Health method", code: "UNKNOWN_ACTION")))
48
+ }
49
+ }
50
+
51
+ func requestAuthorization(types: [String], readOnly: Bool = false, completion: @escaping Completion) {
52
+ guard let healthStore = healthStore else {
53
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
54
+ return
55
+ }
56
+
57
+ var readTypes: Set<HKObjectType> = []
58
+ var shareTypes: Set<HKSampleType> = [HKObjectType.workoutType()]
59
+ if let routeType = HKSeriesType.workoutRoute() as? HKSampleType {
60
+ shareTypes.insert(routeType)
61
+ }
62
+
63
+ for type in types {
64
+ switch type {
65
+ case "steps":
66
+ if let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount) {
67
+ readTypes.insert(stepType)
68
+ }
69
+ case "heartRate":
70
+ if let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) {
71
+ readTypes.insert(heartType)
72
+ }
73
+ case "activeEnergy":
74
+ if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) {
75
+ readTypes.insert(energyType)
76
+ shareTypes.insert(energyType)
77
+ }
78
+ case "distance":
79
+ if let distanceType = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning) {
80
+ readTypes.insert(distanceType)
81
+ shareTypes.insert(distanceType)
82
+ }
83
+ case "workouts":
84
+ readTypes.insert(HKObjectType.workoutType())
85
+ // A workout's own heart rate and energy are read through
86
+ // these; without them every workout comes back bare.
87
+ if let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) {
88
+ readTypes.insert(heartType)
89
+ }
90
+ if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) {
91
+ readTypes.insert(energyType)
92
+ }
93
+ case "restingHeartRate":
94
+ if let restingType = HKQuantityType.quantityType(forIdentifier: .restingHeartRate) {
95
+ readTypes.insert(restingType)
96
+ }
97
+ case "heartRateVariability":
98
+ if let hrvType = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN) {
99
+ readTypes.insert(hrvType)
100
+ }
101
+ case "bodyMass":
102
+ if let massType = HKQuantityType.quantityType(forIdentifier: .bodyMass) {
103
+ readTypes.insert(massType)
104
+ }
105
+ case "sleep":
106
+ if let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) {
107
+ readTypes.insert(sleepType)
108
+ }
109
+ default:
110
+ break
111
+ }
112
+ }
113
+
114
+ // A page that only reads asks only to read: the sheet then says
115
+ // "access", not "access and update".
116
+ if readOnly { shareTypes.removeAll() }
117
+ healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { [weak self] success, error in
118
+ if success {
119
+ completion(.success(true))
120
+ } else {
121
+ completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Authorization failed")))
122
+ }
123
+ }
124
+ }
125
+
126
+ func getData(type: String, startDate: Double?, endDate: Double?, completion: @escaping Completion) {
127
+ guard let healthStore = healthStore else {
128
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
129
+ return
130
+ }
131
+
132
+ guard let spec = Self.healthQuantity(type), let qType = spec.type else {
133
+ completion(.failure(CraftHealthError(message: "Unknown health data type")))
134
+ return
135
+ }
136
+ let qUnit = spec.unit
137
+
138
+ let start = startDate != nil ? Date(timeIntervalSince1970: startDate! / 1000) : Calendar.current.date(byAdding: .day, value: -7, to: Date())!
139
+ let end = endDate != nil ? Date(timeIntervalSince1970: endDate! / 1000) : Date()
140
+
141
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate)
142
+
143
+ // A discrete type (heart rate, weight) has no sum: HealthKit fails
144
+ // the whole query when asked for one, which is what heart rate
145
+ // used to do. Each type is asked for the statistic it has.
146
+ let query = HKStatisticsQuery(quantityType: qType, quantitySamplePredicate: predicate, options: spec.options) { [weak self] _, result, error in
147
+ if let error = error {
148
+ // No samples in the range is an answer, not a failure.
149
+ if (error as? HKError)?.code == .errorNoData {
150
+ completion(.success(["value": 0, "unit": qUnit.unitString]))
151
+ return
152
+ }
153
+ completion(.failure(CraftHealthError(message: error.localizedDescription)))
154
+ return
155
+ }
156
+
157
+ let value = result.flatMap { Self.statisticValue($0, options: spec.options, unit: qUnit) } ?? 0
158
+ completion(.success(["value": value, "unit": qUnit.unitString]))
159
+ }
160
+
161
+ healthStore.execute(query)
162
+ }
163
+
164
+ func saveWorkout(body: [String: Any], completion: @escaping Completion) {
165
+ guard let healthStore = healthStore else {
166
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
167
+ return
168
+ }
169
+ guard let activityId = body["activityId"] as? String,
170
+ let type = body["type"] as? String,
171
+ let startValue = body["startDate"] as? Double,
172
+ let endValue = body["endDate"] as? Double,
173
+ endValue > startValue else {
174
+ completion(.failure(CraftHealthError(message: "A valid activityId, type, startDate, and endDate are required")))
175
+ return
176
+ }
177
+
178
+ let activityType: HKWorkoutActivityType
179
+ switch type {
180
+ case "running": activityType = .running
181
+ case "walking": activityType = .walking
182
+ case "hiking": activityType = .hiking
183
+ case "cycling": activityType = .cycling
184
+ default:
185
+ completion(.failure(CraftHealthError(message: "Unsupported workout type")))
186
+ return
187
+ }
188
+
189
+ let distance = (body["distanceMeters"] as? Double).flatMap { value in
190
+ value > 0 ? HKQuantity(unit: .meter(), doubleValue: value) : nil
191
+ }
192
+ let energy = (body["activeEnergyCalories"] as? Double).flatMap { value in
193
+ value > 0 ? HKQuantity(unit: .kilocalorie(), doubleValue: value) : nil
194
+ }
195
+ let start = Date(timeIntervalSince1970: startValue / 1000)
196
+ let end = Date(timeIntervalSince1970: endValue / 1000)
197
+ let workout = HKWorkout(
198
+ activityType: activityType,
199
+ start: start,
200
+ end: end,
201
+ workoutEvents: nil,
202
+ totalEnergyBurned: energy,
203
+ totalDistance: distance,
204
+ metadata: [HKMetadataKeyExternalUUID: activityId, HKMetadataKeyIndoorWorkout: false]
205
+ )
206
+
207
+ healthStore.save(workout) { [weak self] success, error in
208
+ guard let self = self else { return }
209
+ guard success else {
210
+ completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Workout could not be saved")))
211
+ return
212
+ }
213
+
214
+ let locations = (body["locations"] as? [[String: Any]] ?? []).compactMap { item -> CLLocation? in
215
+ guard let latitude = item["latitude"] as? Double,
216
+ let longitude = item["longitude"] as? Double,
217
+ let timestamp = item["timestamp"] as? Double else { return nil }
218
+ return CLLocation(
219
+ coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude),
220
+ altitude: item["altitude"] as? Double ?? 0,
221
+ horizontalAccuracy: item["accuracy"] as? Double ?? 10,
222
+ verticalAccuracy: -1,
223
+ timestamp: Date(timeIntervalSince1970: timestamp / 1000)
224
+ )
225
+ }.filter { $0.timestamp >= start && $0.timestamp <= end }
226
+
227
+ guard !locations.isEmpty else {
228
+ completion(.success(["id": workout.uuid.uuidString]))
229
+ return
230
+ }
231
+
232
+ let routeBuilder = HKWorkoutRouteBuilder(healthStore: healthStore, device: .local())
233
+ routeBuilder.insertRouteData(locations) { inserted, routeError in
234
+ guard inserted else {
235
+ completion(.failure(CraftHealthError(message: routeError?.localizedDescription ?? "Workout route could not be saved")))
236
+ return
237
+ }
238
+ routeBuilder.finishRoute(with: workout, metadata: [HKMetadataKeyExternalUUID: activityId]) { route, finishError in
239
+ if let finishError = finishError {
240
+ completion(.failure(CraftHealthError(message: finishError.localizedDescription)))
241
+ } else {
242
+ completion(.success([
243
+ "id": workout.uuid.uuidString,
244
+ "routeId": route?.uuid.uuidString ?? ""
245
+ ]))
246
+ }
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ /// The quantity behind each health data type, its unit, and the one
253
+ /// statistic that type supports.
254
+ private static func healthQuantity(_ type: String) -> (type: HKQuantityType?, unit: HKUnit, options: HKStatisticsOptions)? {
255
+ let bpm = HKUnit.count().unitDivided(by: .minute())
256
+ switch type {
257
+ case "steps": return (HKQuantityType.quantityType(forIdentifier: .stepCount), .count(), .cumulativeSum)
258
+ case "activeEnergy": return (HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned), .kilocalorie(), .cumulativeSum)
259
+ case "distance": return (HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning), .meter(), .cumulativeSum)
260
+ case "heartRate": return (HKQuantityType.quantityType(forIdentifier: .heartRate), bpm, .discreteAverage)
261
+ case "restingHeartRate": return (HKQuantityType.quantityType(forIdentifier: .restingHeartRate), bpm, .discreteAverage)
262
+ case "heartRateVariability": return (HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN), .secondUnit(with: .milli), .discreteAverage)
263
+ case "bodyMass": return (HKQuantityType.quantityType(forIdentifier: .bodyMass), .gramUnit(with: .kilo), .mostRecent)
264
+ default: return nil
265
+ }
266
+ }
267
+
268
+ private static func statisticValue(_ statistics: HKStatistics, options: HKStatisticsOptions, unit: HKUnit) -> Double? {
269
+ if options.contains(.cumulativeSum) { return statistics.sumQuantity()?.doubleValue(for: unit) }
270
+ if options.contains(.mostRecent) { return statistics.mostRecentQuantity()?.doubleValue(for: unit) }
271
+ return statistics.averageQuantity()?.doubleValue(for: unit)
272
+ }
273
+
274
+ private static let healthDayFormatter: DateFormatter = {
275
+ let formatter = DateFormatter()
276
+ formatter.calendar = Calendar(identifier: .gregorian)
277
+ formatter.locale = Locale(identifier: "en_US_POSIX")
278
+ formatter.timeZone = .current
279
+ formatter.dateFormat = "yyyy-MM-dd"
280
+ return formatter
281
+ }()
282
+
283
+ /// A stable name for a workout's activity, independent of the SDK's
284
+ /// raw values, so a page can map it to its own sports.
285
+ private static func workoutTypeName(_ type: HKWorkoutActivityType) -> String {
286
+ switch type {
287
+ case .running: return "running"
288
+ case .cycling: return "cycling"
289
+ case .walking: return "walking"
290
+ case .hiking: return "hiking"
291
+ case .swimming: return "swimming"
292
+ case .rowing: return "rowing"
293
+ case .elliptical: return "elliptical"
294
+ case .stairClimbing, .stairs: return "stairClimbing"
295
+ case .yoga: return "yoga"
296
+ case .pilates: return "pilates"
297
+ case .functionalStrengthTraining, .traditionalStrengthTraining, .coreTraining: return "strength"
298
+ case .highIntensityIntervalTraining: return "hiit"
299
+ case .crossTraining, .mixedCardio: return "crossTraining"
300
+ case .crossCountrySkiing: return "crossCountrySkiing"
301
+ case .downhillSkiing, .snowboarding: return "skiing"
302
+ case .paddleSports: return "paddling"
303
+ case .climbing: return "climbing"
304
+ case .dance, .socialDance, .cardioDance: return "dance"
305
+ case .cooldown, .flexibility, .mindAndBody: return "mobility"
306
+ default: return "other"
307
+ }
308
+ }
309
+
310
+ /// Workouts in Apple Health — the watch's, and every app's that
311
+ /// writes there — newest first, with the numbers a training log needs.
312
+ func getWorkouts(startDate: Double?, endDate: Double?, limit: Int?, completion: @escaping Completion) {
313
+ guard let healthStore = healthStore else {
314
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
315
+ return
316
+ }
317
+
318
+ let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -30, to: Date())!
319
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
320
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
321
+ let newestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
322
+ let cap = max(1, min(limit ?? 200, 1000))
323
+
324
+ let query = HKSampleQuery(sampleType: HKObjectType.workoutType(), predicate: predicate, limit: cap, sortDescriptors: [newestFirst]) { [weak self] _, samples, error in
325
+ if let error = error, (error as? HKError)?.code != .errorNoData {
326
+ completion(.failure(CraftHealthError(message: error.localizedDescription)))
327
+ return
328
+ }
329
+
330
+ let bpm = HKUnit.count().unitDivided(by: .minute())
331
+ let workouts: [[String: Any]] = (samples as? [HKWorkout] ?? []).map { workout in
332
+ var item: [String: Any] = [
333
+ "id": workout.uuid.uuidString,
334
+ "type": Self.workoutTypeName(workout.workoutActivityType),
335
+ "startDate": workout.startDate.timeIntervalSince1970 * 1000,
336
+ "endDate": workout.endDate.timeIntervalSince1970 * 1000,
337
+ "durationSeconds": workout.duration,
338
+ "sourceName": workout.sourceRevision.source.name,
339
+ "indoor": (workout.metadata?[HKMetadataKeyIndoorWorkout] as? Bool) ?? false,
340
+ ]
341
+ if let meters = workout.totalDistance?.doubleValue(for: .meter()), meters > 0 {
342
+ item["distanceMeters"] = meters
343
+ }
344
+ if let ascent = workout.metadata?[HKMetadataKeyElevationAscended] as? HKQuantity {
345
+ item["elevationGainMeters"] = ascent.doubleValue(for: .meter())
346
+ }
347
+ if #available(iOS 16.0, *) {
348
+ if let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate),
349
+ let heart = workout.statistics(for: heartType) {
350
+ if let average = heart.averageQuantity()?.doubleValue(for: bpm) { item["averageHeartRate"] = average }
351
+ if let maximum = heart.maximumQuantity()?.doubleValue(for: bpm) { item["maxHeartRate"] = maximum }
352
+ }
353
+ if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned),
354
+ let calories = workout.statistics(for: energyType)?.sumQuantity()?.doubleValue(for: .kilocalorie()) {
355
+ item["activeEnergyCalories"] = calories
356
+ }
357
+ } else if let calories = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) {
358
+ item["activeEnergyCalories"] = calories
359
+ }
360
+ return item
361
+ }
362
+ completion(.success(workouts))
363
+ }
364
+ healthStore.execute(query)
365
+ }
366
+
367
+ /// One value per local day: a sum for steps, energy and distance, an
368
+ /// average for heart rate, resting heart rate and HRV, the latest
369
+ /// weight, and hours asleep for sleep (counted on the day you woke).
370
+ func getDailyStatistics(type: String, startDate: Double?, endDate: Double?, completion: @escaping Completion) {
371
+ guard let healthStore = healthStore else {
372
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
373
+ return
374
+ }
375
+
376
+ let calendar = Calendar.current
377
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
378
+ let start = calendar.startOfDay(for: startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? calendar.date(byAdding: .day, value: -30, to: end)!)
379
+
380
+ if type == "sleep" {
381
+ guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
382
+ completion(.failure(CraftHealthError(message: "Sleep data is unavailable")))
383
+ return
384
+ }
385
+ // From the evening before the first day, so its night counts.
386
+ let predicate = HKQuery.predicateForSamples(withStart: calendar.date(byAdding: .hour, value: -12, to: start), end: end, options: [])
387
+ let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { [weak self] _, samples, error in
388
+ if let error = error, (error as? HKError)?.code != .errorNoData {
389
+ completion(.failure(CraftHealthError(message: error.localizedDescription)))
390
+ return
391
+ }
392
+ // Time in bed and time awake are not sleep; every asleep
393
+ // stage (and the older single "asleep" value) is.
394
+ let awake: Set<Int> = [HKCategoryValueSleepAnalysis.inBed.rawValue, HKCategoryValueSleepAnalysis.awake.rawValue]
395
+ var secondsByDay: [String: Double] = [:]
396
+ for sample in samples as? [HKCategorySample] ?? [] where !awake.contains(sample.value) {
397
+ let day = Self.healthDayFormatter.string(from: sample.endDate)
398
+ secondsByDay[day, default: 0] += sample.endDate.timeIntervalSince(sample.startDate)
399
+ }
400
+ let days = secondsByDay.keys.sorted().map { day -> [String: Any] in
401
+ ["date": day, "value": (secondsByDay[day]! / 3600 * 100).rounded() / 100, "unit": "hr"]
402
+ }
403
+ completion(.success(days))
404
+ }
405
+ healthStore.execute(query)
406
+ return
407
+ }
408
+
409
+ guard let spec = Self.healthQuantity(type), let qType = spec.type else {
410
+ completion(.failure(CraftHealthError(message: "Unknown health data type")))
411
+ return
412
+ }
413
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
414
+ let query = HKStatisticsCollectionQuery(
415
+ quantityType: qType,
416
+ quantitySamplePredicate: predicate,
417
+ options: spec.options,
418
+ anchorDate: start,
419
+ intervalComponents: DateComponents(day: 1)
420
+ )
421
+ query.initialResultsHandler = { [weak self] _, collection, error in
422
+ if let error = error, (error as? HKError)?.code != .errorNoData {
423
+ completion(.failure(CraftHealthError(message: error.localizedDescription)))
424
+ return
425
+ }
426
+ var days: [[String: Any]] = []
427
+ collection?.enumerateStatistics(from: start, to: end) { statistics, _ in
428
+ guard let value = Self.statisticValue(statistics, options: spec.options, unit: spec.unit) else { return }
429
+ days.append([
430
+ "date": Self.healthDayFormatter.string(from: statistics.startDate),
431
+ "value": value,
432
+ "unit": spec.unit.unitString,
433
+ ])
434
+ }
435
+ completion(.success(days))
436
+ }
437
+ healthStore.execute(query)
438
+ }
439
+
440
+ /// Every heart-rate reading between two instants, oldest first: the
441
+ /// beats per minute, when it was taken, and the app and device that
442
+ /// wrote it. A watch's workout readings can arrive as one series
443
+ /// sample; those also carry their count, lowest and highest.
444
+ func getHeartRateSamples(startDate: Double?, endDate: Double?, limit: Int?, completion: @escaping Completion) {
445
+ guard let healthStore = healthStore else {
446
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
447
+ return
448
+ }
449
+ guard let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) else {
450
+ completion(.failure(CraftHealthError(message: "Heart rate is unavailable")))
451
+ return
452
+ }
453
+
454
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
455
+ let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -1, to: end)!
456
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
457
+ let oldestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
458
+ let cap = max(1, min(limit ?? 10_000, 50_000))
459
+
460
+ let query = HKSampleQuery(sampleType: heartType, predicate: predicate, limit: cap, sortDescriptors: [oldestFirst]) { [weak self] _, samples, error in
461
+ if let error = error, (error as? HKError)?.code != .errorNoData {
462
+ completion(.failure(CraftHealthError(message: error.localizedDescription)))
463
+ return
464
+ }
465
+
466
+ let bpm = HKUnit.count().unitDivided(by: .minute())
467
+ let rows: [[String: Any]] = (samples as? [HKQuantitySample] ?? []).map { sample in
468
+ var item: [String: Any] = [
469
+ "bpm": sample.quantity.doubleValue(for: bpm),
470
+ "startDate": sample.startDate.timeIntervalSince1970 * 1000,
471
+ "endDate": sample.endDate.timeIntervalSince1970 * 1000,
472
+ "sourceName": sample.sourceRevision.source.name,
473
+ ]
474
+ if let device = sample.device?.name { item["deviceName"] = device }
475
+ if let model = sample.device?.model { item["deviceModel"] = model }
476
+ if let series = sample as? HKDiscreteQuantitySample, series.count > 1 {
477
+ item["count"] = series.count
478
+ item["min"] = series.minimumQuantity.doubleValue(for: bpm)
479
+ item["max"] = series.maximumQuantity.doubleValue(for: bpm)
480
+ }
481
+ if let raw = sample.metadata?[HKMetadataKeyHeartRateMotionContext] as? NSNumber {
482
+ switch HKHeartRateMotionContext(rawValue: raw.intValue) {
483
+ case .sedentary: item["motionContext"] = "sedentary"
484
+ case .active: item["motionContext"] = "active"
485
+ default: break
486
+ }
487
+ }
488
+ return item
489
+ }
490
+ completion(.success(rows))
491
+ }
492
+ healthStore.execute(query)
493
+ }
494
+
495
+ /// A stable name for each sleep analysis value: in bed, awake, or one
496
+ /// of the asleep stages (`asleep` when the source does not stage it).
497
+ private static func sleepStageName(_ value: Int) -> String {
498
+ switch HKCategoryValueSleepAnalysis(rawValue: value) {
499
+ case .inBed: return "inBed"
500
+ case .awake: return "awake"
501
+ case .asleepCore: return "core"
502
+ case .asleepDeep: return "deep"
503
+ case .asleepREM: return "rem"
504
+ default: return "asleep"
505
+ }
506
+ }
507
+
508
+ /// Sleep between two instants, oldest first, as the sources wrote it:
509
+ /// time in bed and every asleep or awake stretch, each with its stage
510
+ /// and whether it counts as asleep, so a page can mark the night.
511
+ func getSleepIntervals(startDate: Double?, endDate: Double?, completion: @escaping Completion) {
512
+ guard let healthStore = healthStore else {
513
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
514
+ return
515
+ }
516
+ guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
517
+ completion(.failure(CraftHealthError(message: "Sleep data is unavailable")))
518
+ return
519
+ }
520
+
521
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
522
+ let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -1, to: end)!
523
+ // Any interval that overlaps the range, so a night that began
524
+ // before it still counts.
525
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
526
+ let oldestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
527
+
528
+ let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [oldestFirst]) { [weak self] _, samples, error in
529
+ if let error = error, (error as? HKError)?.code != .errorNoData {
530
+ completion(.failure(CraftHealthError(message: error.localizedDescription)))
531
+ return
532
+ }
533
+
534
+ let notAsleep: Set<Int> = [HKCategoryValueSleepAnalysis.inBed.rawValue, HKCategoryValueSleepAnalysis.awake.rawValue]
535
+ let rows: [[String: Any]] = (samples as? [HKCategorySample] ?? []).map { sample in
536
+ var item: [String: Any] = [
537
+ "startDate": sample.startDate.timeIntervalSince1970 * 1000,
538
+ "endDate": sample.endDate.timeIntervalSince1970 * 1000,
539
+ "stage": Self.sleepStageName(sample.value),
540
+ "asleep": !notAsleep.contains(sample.value),
541
+ "sourceName": sample.sourceRevision.source.name,
542
+ ]
543
+ if let device = sample.device?.name { item["deviceName"] = device }
544
+ return item
545
+ }
546
+ completion(.success(rows))
547
+ }
548
+ healthStore.execute(query)
549
+ }
550
+
551
+ }
@@ -44,6 +44,7 @@ enum CraftNativeActions {
44
44
  if config.enableLocalDatabase { values.append("database") }
45
45
  if config.enableDeepLinks { values.append("deepLinks") }
46
46
  if config.enableLocalNotifications { values.append("notifications") }
47
+ if config.enableHealthKit { values.append("health") }
47
48
  return values
48
49
  }
49
50
 
@@ -145,6 +146,16 @@ enum CraftNativeActions {
145
146
  }
146
147
  databaseQueue.async { complete(requestToken, database(method: method, args: args), completion) }
147
148
  return
149
+ case ("Health", _):
150
+ // Apple Health, the same as a page's `craft.health` (CraftHealth.swift).
151
+ guard config.enableHealthKit else {
152
+ complete(requestToken, failure("CAPABILITY_DISABLED", "HealthKit is disabled"), completion)
153
+ return
154
+ }
155
+ CraftHealth.shared.perform(method: method, args: args) { result in
156
+ complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
157
+ }
158
+ return
148
159
  case ("Notifications", _):
149
160
  guard config.enableLocalNotifications else {
150
161
  complete(requestToken, failure("CAPABILITY_DISABLED", "Local notifications are disabled"), completion)