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.
- package/dist/android/templates/CraftNative.kt.template +137 -0
- package/dist/android/templates/MainActivityNative.kt.template +822 -87
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.js +1 -0
- package/dist/ios/templates/CraftApp.swift +14 -482
- package/dist/ios/templates/CraftHealth.swift +551 -0
- package/dist/ios/templates/CraftNativeActions.swift +11 -0
- package/dist/ios/templates/CraftNativeScreen.swift +753 -52
- package/package.json +1 -1
|
@@ -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)
|