@flomentumsolutions/capacitor-health-extended 0.0.1
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/CapacitorHealthExtended.podspec +17 -0
- package/LICENSE +21 -0
- package/Package.swift +28 -0
- package/README.md +343 -0
- package/android/build.gradle +70 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/flomentum/health/capacitor/HealthPlugin.kt +677 -0
- package/android/src/main/java/com/flomentum/health/capacitor/PermissionsRationaleActivity.kt +86 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/esm/definitions.d.ts +110 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/plugin.cjs.js +10 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +13 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/HealthPluginPlugin/HealthPlugin.swift +671 -0
- package/package.json +85 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Capacitor
|
|
3
|
+
import HealthKit
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Please read the Capacitor iOS Plugin Development Guide
|
|
7
|
+
* here: https://capacitorjs.com/docs/plugins/ios
|
|
8
|
+
*/
|
|
9
|
+
@objc(HealthPlugin)
|
|
10
|
+
public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
11
|
+
public let identifier = "HealthPlugin"
|
|
12
|
+
public let jsName = "HealthPlugin"
|
|
13
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
14
|
+
CAPPluginMethod(name: "isHealthAvailable", returnType: CAPPluginReturnPromise),
|
|
15
|
+
CAPPluginMethod(name: "checkHealthPermissions", returnType: CAPPluginReturnPromise),
|
|
16
|
+
CAPPluginMethod(name: "requestHealthPermissions", returnType: CAPPluginReturnPromise),
|
|
17
|
+
CAPPluginMethod(name: "openAppleHealthSettings", returnType: CAPPluginReturnPromise),
|
|
18
|
+
CAPPluginMethod(name: "queryAggregated", returnType: CAPPluginReturnPromise),
|
|
19
|
+
CAPPluginMethod(name: "queryWorkouts", returnType: CAPPluginReturnPromise),
|
|
20
|
+
CAPPluginMethod(name: "queryLatestSample", returnType: CAPPluginReturnPromise)
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
let healthStore = HKHealthStore()
|
|
24
|
+
|
|
25
|
+
@objc func isHealthAvailable(_ call: CAPPluginCall) {
|
|
26
|
+
let isAvailable = HKHealthStore.isHealthDataAvailable()
|
|
27
|
+
call.resolve(["available": isAvailable])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@objc func checkHealthPermissions(_ call: CAPPluginCall) {
|
|
31
|
+
guard let permissions = call.getArray("permissions") as? [String] else {
|
|
32
|
+
call.reject("Invalid permissions format")
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
var result: [String: String] = [:]
|
|
37
|
+
|
|
38
|
+
for permission in permissions {
|
|
39
|
+
let hkTypes = permissionToHKObjectType(permission)
|
|
40
|
+
for type in hkTypes {
|
|
41
|
+
let status = healthStore.authorizationStatus(for: type)
|
|
42
|
+
|
|
43
|
+
switch status {
|
|
44
|
+
case .notDetermined:
|
|
45
|
+
result[permission] = "notDetermined"
|
|
46
|
+
case .sharingDenied:
|
|
47
|
+
result[permission] = "denied"
|
|
48
|
+
case .sharingAuthorized:
|
|
49
|
+
result[permission] = "authorized"
|
|
50
|
+
@unknown default:
|
|
51
|
+
result[permission] = "unknown"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
call.resolve(["permissions": result])
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@objc func requestHealthPermissions(_ call: CAPPluginCall) {
|
|
60
|
+
guard let permissions = call.getArray("permissions") as? [String] else {
|
|
61
|
+
call.reject("Invalid permissions format")
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let types: [HKObjectType] = permissions.flatMap { permissionToHKObjectType($0) }
|
|
66
|
+
|
|
67
|
+
healthStore.requestAuthorization(toShare: nil, read: Set(types)) { success, error in
|
|
68
|
+
if success {
|
|
69
|
+
//we don't know which actual permissions were granted, so we assume all
|
|
70
|
+
var result: [String: Bool] = [:]
|
|
71
|
+
permissions.forEach{ result[$0] = true }
|
|
72
|
+
call.resolve(["permissions": result])
|
|
73
|
+
} else if let error = error {
|
|
74
|
+
call.reject("Authorization failed: \(error.localizedDescription)")
|
|
75
|
+
} else {
|
|
76
|
+
//assume no permissions were granted. We can ask user to adjust them manually
|
|
77
|
+
var result: [String: Bool] = [:]
|
|
78
|
+
permissions.forEach{ result[$0] = false }
|
|
79
|
+
call.resolve(["permissions": result])
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@objc func queryLatestSample(_ call: CAPPluginCall) {
|
|
85
|
+
guard let dataTypeString = call.getString("dataType") else {
|
|
86
|
+
call.reject("Missing data type")
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
guard aggregateTypeToHKQuantityType(dataTypeString) != nil else {
|
|
90
|
+
call.reject("Invalid data type")
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let quantityType: HKQuantityType? = {
|
|
95
|
+
switch dataTypeString {
|
|
96
|
+
case "heart-rate":
|
|
97
|
+
return HKObjectType.quantityType(forIdentifier: .heartRate)
|
|
98
|
+
case "weight":
|
|
99
|
+
return HKObjectType.quantityType(forIdentifier: .bodyMass)
|
|
100
|
+
case "steps":
|
|
101
|
+
return HKObjectType.quantityType(forIdentifier: .stepCount)
|
|
102
|
+
default:
|
|
103
|
+
return nil
|
|
104
|
+
}
|
|
105
|
+
}()
|
|
106
|
+
|
|
107
|
+
guard let type = quantityType else {
|
|
108
|
+
call.reject("Invalid or unsupported data type")
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
|
|
113
|
+
let predicate = HKQuery.predicateForSamples(withStart: Date.distantPast, end: Date(), options: .strictEndDate)
|
|
114
|
+
|
|
115
|
+
let query = HKSampleQuery(sampleType: type, predicate: predicate, limit: 1, sortDescriptors: [sortDescriptor]) { _, samples, error in
|
|
116
|
+
|
|
117
|
+
print("Samples count for \(dataTypeString):", samples?.count ?? 0)
|
|
118
|
+
|
|
119
|
+
guard let quantitySample = samples?.first as? HKQuantitySample else {
|
|
120
|
+
if let error = error {
|
|
121
|
+
call.reject("Error fetching latest sample", "NO_SAMPLE", error)
|
|
122
|
+
} else {
|
|
123
|
+
call.reject("No sample found", "NO_SAMPLE")
|
|
124
|
+
}
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
var unit: HKUnit = .count()
|
|
129
|
+
if dataTypeString == "heart-rate" {
|
|
130
|
+
unit = HKUnit.count().unitDivided(by: HKUnit.minute())
|
|
131
|
+
} else if dataTypeString == "weight" {
|
|
132
|
+
unit = .gramUnit(with: .kilo)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let value = quantitySample.quantity.doubleValue(for: unit)
|
|
136
|
+
let timestamp = quantitySample.startDate.timeIntervalSince1970 * 1000
|
|
137
|
+
|
|
138
|
+
call.resolve([
|
|
139
|
+
"value": value,
|
|
140
|
+
"timestamp": timestamp,
|
|
141
|
+
"unit": unit.unitString
|
|
142
|
+
])
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
healthStore.execute(query)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@objc func openAppleHealthSettings(_ call: CAPPluginCall) {
|
|
149
|
+
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
150
|
+
DispatchQueue.main.async {
|
|
151
|
+
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
|
152
|
+
call.resolve()
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
call.reject("Unable to open app-specific settings")
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Permission helpers
|
|
160
|
+
func permissionToHKObjectType(_ permission: String) -> [HKObjectType] {
|
|
161
|
+
switch permission {
|
|
162
|
+
case "READ_STEPS":
|
|
163
|
+
return [HKObjectType.quantityType(forIdentifier: .stepCount)].compactMap{$0}
|
|
164
|
+
case "READ_WEIGHT":
|
|
165
|
+
return [HKObjectType.quantityType(forIdentifier: .bodyMass)].compactMap{$0}
|
|
166
|
+
case "READ_ACTIVE_CALORIES":
|
|
167
|
+
return [HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)].compactMap{$0}
|
|
168
|
+
case "READ_WORKOUTS":
|
|
169
|
+
return [HKObjectType.workoutType()].compactMap{$0}
|
|
170
|
+
case "READ_HEART_RATE":
|
|
171
|
+
return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap{$0}
|
|
172
|
+
case "READ_ROUTE":
|
|
173
|
+
return [HKSeriesType.workoutRoute()].compactMap{$0}
|
|
174
|
+
case "READ_DISTANCE":
|
|
175
|
+
return [
|
|
176
|
+
HKObjectType.quantityType(forIdentifier: .distanceCycling),
|
|
177
|
+
HKObjectType.quantityType(forIdentifier: .distanceSwimming),
|
|
178
|
+
HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning),
|
|
179
|
+
HKObjectType.quantityType(forIdentifier: .distanceDownhillSnowSports)
|
|
180
|
+
].compactMap{$0}
|
|
181
|
+
case "READ_MINDFULNESS":
|
|
182
|
+
return [HKObjectType.categoryType(forIdentifier: .mindfulSession)!].compactMap{$0}
|
|
183
|
+
default:
|
|
184
|
+
return []
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func aggregateTypeToHKQuantityType(_ dataType: String) -> HKQuantityType? {
|
|
189
|
+
switch dataType {
|
|
190
|
+
case "steps":
|
|
191
|
+
return HKObjectType.quantityType(forIdentifier: .stepCount)
|
|
192
|
+
case "active-calories":
|
|
193
|
+
return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
|
|
194
|
+
case "heart-rate":
|
|
195
|
+
return HKObjectType.quantityType(forIdentifier: .heartRate)
|
|
196
|
+
case "weight":
|
|
197
|
+
return HKObjectType.quantityType(forIdentifier: .bodyMass)
|
|
198
|
+
default:
|
|
199
|
+
return nil
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@objc func queryAggregated(_ call: CAPPluginCall) {
|
|
205
|
+
guard let startDateString = call.getString("startDate"),
|
|
206
|
+
let endDateString = call.getString("endDate"),
|
|
207
|
+
let dataTypeString = call.getString("dataType"),
|
|
208
|
+
let bucket = call.getString("bucket"),
|
|
209
|
+
let startDate = self.isoDateFormatter.date(from: startDateString),
|
|
210
|
+
let endDate = self.isoDateFormatter.date(from: endDateString) else {
|
|
211
|
+
call.reject("Invalid parameters")
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if(dataTypeString == "mindfulness") {
|
|
216
|
+
self.queryMindfulnessAggregated(startDate: startDate, endDate: endDate) {result, error in
|
|
217
|
+
if let error = error {
|
|
218
|
+
call.reject(error.localizedDescription)
|
|
219
|
+
} else if let result = result {
|
|
220
|
+
call.resolve(["aggregatedData": result])
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
|
225
|
+
|
|
226
|
+
guard let interval = calculateInterval(bucket: bucket) else {
|
|
227
|
+
call.reject("Invalid bucket")
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
guard let dataType = aggregateTypeToHKQuantityType(dataTypeString) else {
|
|
232
|
+
call.reject("Invalid data type")
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let options: HKStatisticsOptions = {
|
|
237
|
+
switch dataType.identifier {
|
|
238
|
+
case HKQuantityTypeIdentifier.heartRate.rawValue,
|
|
239
|
+
HKQuantityTypeIdentifier.bodyMass.rawValue:
|
|
240
|
+
return .discreteAverage
|
|
241
|
+
default:
|
|
242
|
+
return .cumulativeSum
|
|
243
|
+
}
|
|
244
|
+
}()
|
|
245
|
+
|
|
246
|
+
let query = HKStatisticsCollectionQuery(
|
|
247
|
+
quantityType: dataType,
|
|
248
|
+
quantitySamplePredicate: predicate,
|
|
249
|
+
options: options,
|
|
250
|
+
anchorDate: startDate,
|
|
251
|
+
intervalComponents: interval
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
query.initialResultsHandler = { query, result, error in
|
|
255
|
+
if let error = error {
|
|
256
|
+
call.reject("Error fetching aggregated data: \(error.localizedDescription)")
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
var aggregatedSamples: [[String: Any]] = []
|
|
261
|
+
|
|
262
|
+
result?.enumerateStatistics(from: startDate, to: endDate) { statistics, stop in
|
|
263
|
+
if let sum = statistics.sumQuantity() {
|
|
264
|
+
let startDate = statistics.startDate.timeIntervalSince1970 * 1000
|
|
265
|
+
let endDate = statistics.endDate.timeIntervalSince1970 * 1000
|
|
266
|
+
|
|
267
|
+
var value: Double = -1.0
|
|
268
|
+
if(dataTypeString == "steps" && dataType.is(compatibleWith: HKUnit.count())) {
|
|
269
|
+
value = sum.doubleValue(for: HKUnit.count())
|
|
270
|
+
} else if(dataTypeString == "active-calories" && dataType.is(compatibleWith: HKUnit.kilocalorie())) {
|
|
271
|
+
value = sum.doubleValue(for: HKUnit.kilocalorie())
|
|
272
|
+
} else if(dataTypeString == "mindfulness" && dataType.is(compatibleWith: HKUnit.second())) {
|
|
273
|
+
value = sum.doubleValue(for: HKUnit.second())
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
aggregatedSamples.append([
|
|
278
|
+
"startDate": startDate,
|
|
279
|
+
"endDate": endDate,
|
|
280
|
+
"value": value
|
|
281
|
+
])
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
call.resolve(["aggregatedData": aggregatedSamples])
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
healthStore.execute(query)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
func queryMindfulnessAggregated(startDate: Date, endDate: Date, completion: @escaping ([[String: Any]]?, Error?) -> Void) {
|
|
293
|
+
guard let mindfulType = HKObjectType.categoryType(forIdentifier: .mindfulSession) else {
|
|
294
|
+
completion(nil, NSError(domain: "HealthKit", code: -1, userInfo: [NSLocalizedDescriptionKey: "MindfulSession type unavailable"]))
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
|
299
|
+
let query = HKSampleQuery(sampleType: mindfulType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
300
|
+
guard let categorySamples = samples as? [HKCategorySample], error == nil else {
|
|
301
|
+
completion(nil, error)
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Aggregate total time per day
|
|
306
|
+
|
|
307
|
+
var dailyDurations: [Date: TimeInterval] = [:]
|
|
308
|
+
let calendar = Calendar.current
|
|
309
|
+
|
|
310
|
+
for sample in categorySamples {
|
|
311
|
+
let startOfDay = calendar.startOfDay(for: sample.startDate)
|
|
312
|
+
let duration = sample.endDate.timeIntervalSince(sample.startDate)
|
|
313
|
+
|
|
314
|
+
if let existingDuration = dailyDurations[startOfDay] {
|
|
315
|
+
dailyDurations[startOfDay] = existingDuration + duration
|
|
316
|
+
} else {
|
|
317
|
+
dailyDurations[startOfDay] = duration
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
var aggregatedSamples: [[String: Any]] = []
|
|
322
|
+
var dayComponent = DateComponents()
|
|
323
|
+
dayComponent.day = 1
|
|
324
|
+
dailyDurations.forEach { (dateAndDuration) in
|
|
325
|
+
aggregatedSamples.append([
|
|
326
|
+
"startDate": dateAndDuration.key as Any,
|
|
327
|
+
"endDate": calendar.date(byAdding: dayComponent, to: dateAndDuration.key) as Any,
|
|
328
|
+
"value": dateAndDuration.value
|
|
329
|
+
])
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
completion(aggregatedSamples, nil)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
healthStore.execute(query)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
private func queryAggregated(for startDate: Date, for endDate: Date, for dataType: HKQuantityType?, completion: @escaping(Double?) -> Void) {
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
guard let quantityType = dataType else {
|
|
344
|
+
completion(nil)
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
|
349
|
+
|
|
350
|
+
let query = HKStatisticsQuery(
|
|
351
|
+
quantityType: quantityType,
|
|
352
|
+
quantitySamplePredicate: predicate,
|
|
353
|
+
options: .cumulativeSum
|
|
354
|
+
) { _, result, _ in
|
|
355
|
+
guard let result = result, let sum = result.sumQuantity() else {
|
|
356
|
+
completion(0.0)
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
completion(sum.doubleValue(for: HKUnit.count()))
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
healthStore.execute(query)
|
|
363
|
+
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
func calculateInterval(bucket: String) -> DateComponents? {
|
|
371
|
+
switch bucket {
|
|
372
|
+
case "hour":
|
|
373
|
+
return DateComponents(hour: 1)
|
|
374
|
+
case "day":
|
|
375
|
+
return DateComponents(day: 1)
|
|
376
|
+
case "week":
|
|
377
|
+
return DateComponents(weekOfYear: 1)
|
|
378
|
+
default:
|
|
379
|
+
return nil
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
var isoDateFormatter: ISO8601DateFormatter = {
|
|
384
|
+
let f = ISO8601DateFormatter()
|
|
385
|
+
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
386
|
+
return f
|
|
387
|
+
}()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@objc func queryWorkouts(_ call: CAPPluginCall) {
|
|
391
|
+
guard let startDateString = call.getString("startDate"),
|
|
392
|
+
let endDateString = call.getString("endDate"),
|
|
393
|
+
let includeHeartRate = call.getBool("includeHeartRate"),
|
|
394
|
+
let includeRoute = call.getBool("includeRoute"),
|
|
395
|
+
let includeSteps = call.getBool("includeSteps"),
|
|
396
|
+
let startDate = self.isoDateFormatter.date(from: startDateString),
|
|
397
|
+
let endDate = self.isoDateFormatter.date(from: endDateString) else {
|
|
398
|
+
call.reject("Invalid parameters")
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
// Create a predicate to filter workouts by date
|
|
405
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
|
406
|
+
|
|
407
|
+
let workoutQuery = HKSampleQuery(sampleType: HKObjectType.workoutType(), predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { query, samples, error in
|
|
408
|
+
if let error = error {
|
|
409
|
+
call.reject("Error querying workouts: \(error.localizedDescription)")
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
guard let workouts = samples as? [HKWorkout] else {
|
|
414
|
+
call.resolve(["workouts": []])
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
var workoutList: [[String: Any]] = []
|
|
419
|
+
var errors: [String: String] = [:]
|
|
420
|
+
let dispatchGroup = DispatchGroup()
|
|
421
|
+
|
|
422
|
+
// Process each workout
|
|
423
|
+
for workout in workouts {
|
|
424
|
+
var workoutDict: [String: Any] = [
|
|
425
|
+
"startDate": workout.startDate,
|
|
426
|
+
"endDate": workout.endDate,
|
|
427
|
+
"workoutType": self.workoutTypeMapping[workout.workoutActivityType.rawValue, default: "other"],
|
|
428
|
+
"sourceName": workout.sourceRevision.source.name,
|
|
429
|
+
"sourceBundleId": workout.sourceRevision.source.bundleIdentifier,
|
|
430
|
+
"id": workout.uuid.uuidString,
|
|
431
|
+
"duration": workout.duration,
|
|
432
|
+
"calories": workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) ?? 0,
|
|
433
|
+
"distance": workout.totalDistance?.doubleValue(for: .meter()) ?? 0
|
|
434
|
+
]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
var heartRateSamples: [[String: Any]] = []
|
|
438
|
+
var routeSamples: [[String: Any]] = []
|
|
439
|
+
|
|
440
|
+
// Query heart rate data if requested
|
|
441
|
+
if includeHeartRate {
|
|
442
|
+
dispatchGroup.enter()
|
|
443
|
+
self.queryHeartRate(for: workout, completion: { (heartRates, error) in
|
|
444
|
+
if(error != nil) {
|
|
445
|
+
errors["heart-rate"] = error
|
|
446
|
+
}
|
|
447
|
+
heartRateSamples = heartRates
|
|
448
|
+
dispatchGroup.leave()
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Query route data if requested
|
|
453
|
+
if includeRoute {
|
|
454
|
+
dispatchGroup.enter()
|
|
455
|
+
self.queryRoute(for: workout, completion: { (routes, error) in
|
|
456
|
+
if(error != nil) {
|
|
457
|
+
errors["route"] = error
|
|
458
|
+
}
|
|
459
|
+
routeSamples = routes
|
|
460
|
+
dispatchGroup.leave()
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if includeSteps {
|
|
465
|
+
dispatchGroup.enter()
|
|
466
|
+
self.queryAggregated(for: workout.startDate, for: workout.endDate, for: HKObjectType.quantityType(forIdentifier: .stepCount), completion:{ (steps) in
|
|
467
|
+
if(steps != nil) {
|
|
468
|
+
workoutDict["steps"] = steps
|
|
469
|
+
}
|
|
470
|
+
dispatchGroup.leave()
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
dispatchGroup.notify(queue: .main) {
|
|
475
|
+
workoutDict["heartRate"] = heartRateSamples
|
|
476
|
+
workoutDict["route"] = routeSamples
|
|
477
|
+
workoutList.append(workoutDict)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
dispatchGroup.notify(queue: .main) {
|
|
484
|
+
call.resolve(["workouts": workoutList, "errors": errors])
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
healthStore.execute(workoutQuery)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
// MARK: - Query Heart Rate Data
|
|
494
|
+
private func queryHeartRate(for workout: HKWorkout, completion: @escaping ([[String: Any]], String?) -> Void) {
|
|
495
|
+
let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate)!
|
|
496
|
+
let predicate = HKQuery.predicateForSamples(withStart: workout.startDate, end: workout.endDate, options: .strictStartDate)
|
|
497
|
+
|
|
498
|
+
let heartRateQuery = HKSampleQuery(sampleType: heartRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { query, samples, error in
|
|
499
|
+
guard let heartRateSamplesData = samples as? [HKQuantitySample], error == nil else {
|
|
500
|
+
completion([], error?.localizedDescription)
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
var heartRateSamples: [[String: Any]] = []
|
|
505
|
+
|
|
506
|
+
for sample in heartRateSamplesData {
|
|
507
|
+
let heartRateUnit = HKUnit.count().unitDivided(by: HKUnit.minute())
|
|
508
|
+
|
|
509
|
+
let sampleDict: [String: Any] = [
|
|
510
|
+
"timestamp": sample.startDate,
|
|
511
|
+
"bpm": sample.quantity.doubleValue(for: heartRateUnit)
|
|
512
|
+
]
|
|
513
|
+
|
|
514
|
+
heartRateSamples.append(sampleDict)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
completion(heartRateSamples, nil)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
healthStore.execute(heartRateQuery)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// MARK: - Query Route Data
|
|
525
|
+
private func queryRoute(for workout: HKWorkout, completion: @escaping ([[String: Any]], String?) -> Void) {
|
|
526
|
+
let routeType = HKSeriesType.workoutRoute()
|
|
527
|
+
let predicate = HKQuery.predicateForObjects(from: workout)
|
|
528
|
+
|
|
529
|
+
let routeQuery = HKSampleQuery(sampleType: routeType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { query, samples, error in
|
|
530
|
+
guard let routes = samples as? [HKWorkoutRoute], error == nil else {
|
|
531
|
+
completion([], error?.localizedDescription)
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
var routeLocations: [[String: Any]] = []
|
|
536
|
+
let routeDispatchGroup = DispatchGroup()
|
|
537
|
+
|
|
538
|
+
// Query locations for each route
|
|
539
|
+
for route in routes {
|
|
540
|
+
routeDispatchGroup.enter()
|
|
541
|
+
self.queryLocations(for: route) { locations in
|
|
542
|
+
routeLocations.append(contentsOf: locations)
|
|
543
|
+
routeDispatchGroup.leave()
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
routeDispatchGroup.notify(queue: .main) {
|
|
548
|
+
completion(routeLocations, nil)
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
healthStore.execute(routeQuery)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// MARK: - Query Route Locations
|
|
556
|
+
private func queryLocations(for route: HKWorkoutRoute, completion: @escaping ([[String: Any]]) -> Void) {
|
|
557
|
+
var routeLocations: [[String: Any]] = []
|
|
558
|
+
|
|
559
|
+
let locationQuery = HKWorkoutRouteQuery(route: route) { query, locations, done, error in
|
|
560
|
+
guard let locations = locations, error == nil else {
|
|
561
|
+
completion([])
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
for location in locations {
|
|
566
|
+
let locationDict: [String: Any] = [
|
|
567
|
+
"timestamp": location.timestamp,
|
|
568
|
+
"lat": location.coordinate.latitude,
|
|
569
|
+
"lng": location.coordinate.longitude,
|
|
570
|
+
"alt": location.altitude
|
|
571
|
+
]
|
|
572
|
+
routeLocations.append(locationDict)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if done {
|
|
576
|
+
completion(routeLocations)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
healthStore.execute(locationQuery)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
let workoutTypeMapping: [UInt : String] = [
|
|
585
|
+
1 : "americanFootball" ,
|
|
586
|
+
2 : "archery" ,
|
|
587
|
+
3 : "australianFootball" ,
|
|
588
|
+
4 : "badminton" ,
|
|
589
|
+
5 : "baseball" ,
|
|
590
|
+
6 : "basketball" ,
|
|
591
|
+
7 : "bowling" ,
|
|
592
|
+
8 : "boxing" ,
|
|
593
|
+
9 : "climbing" ,
|
|
594
|
+
10 : "cricket" ,
|
|
595
|
+
11 : "crossTraining" ,
|
|
596
|
+
12 : "curling" ,
|
|
597
|
+
13 : "cycling" ,
|
|
598
|
+
14 : "dance" ,
|
|
599
|
+
15 : "danceInspiredTraining" ,
|
|
600
|
+
16 : "elliptical" ,
|
|
601
|
+
17 : "equestrianSports" ,
|
|
602
|
+
18 : "fencing" ,
|
|
603
|
+
19 : "fishing" ,
|
|
604
|
+
20 : "functionalStrengthTraining" ,
|
|
605
|
+
21 : "golf" ,
|
|
606
|
+
22 : "gymnastics" ,
|
|
607
|
+
23 : "handball" ,
|
|
608
|
+
24 : "hiking" ,
|
|
609
|
+
25 : "hockey" ,
|
|
610
|
+
26 : "hunting" ,
|
|
611
|
+
27 : "lacrosse" ,
|
|
612
|
+
28 : "martialArts" ,
|
|
613
|
+
29 : "mindAndBody" ,
|
|
614
|
+
30 : "mixedMetabolicCardioTraining" ,
|
|
615
|
+
31 : "paddleSports" ,
|
|
616
|
+
32 : "play" ,
|
|
617
|
+
33 : "preparationAndRecovery" ,
|
|
618
|
+
34 : "racquetball" ,
|
|
619
|
+
35 : "rowing" ,
|
|
620
|
+
36 : "rugby" ,
|
|
621
|
+
37 : "running" ,
|
|
622
|
+
38 : "sailing" ,
|
|
623
|
+
39 : "skatingSports" ,
|
|
624
|
+
40 : "snowSports" ,
|
|
625
|
+
41 : "soccer" ,
|
|
626
|
+
42 : "softball" ,
|
|
627
|
+
43 : "squash" ,
|
|
628
|
+
44 : "stairClimbing" ,
|
|
629
|
+
45 : "surfingSports" ,
|
|
630
|
+
46 : "swimming" ,
|
|
631
|
+
47 : "tableTennis" ,
|
|
632
|
+
48 : "tennis" ,
|
|
633
|
+
49 : "trackAndField" ,
|
|
634
|
+
50 : "traditionalStrengthTraining" ,
|
|
635
|
+
51 : "volleyball" ,
|
|
636
|
+
52 : "walking" ,
|
|
637
|
+
53 : "waterFitness" ,
|
|
638
|
+
54 : "waterPolo" ,
|
|
639
|
+
55 : "waterSports" ,
|
|
640
|
+
56 : "wrestling" ,
|
|
641
|
+
57 : "yoga" ,
|
|
642
|
+
58 : "barre" ,
|
|
643
|
+
59 : "coreTraining" ,
|
|
644
|
+
60 : "crossCountrySkiing" ,
|
|
645
|
+
61 : "downhillSkiing" ,
|
|
646
|
+
62 : "flexibility" ,
|
|
647
|
+
63 : "highIntensityIntervalTraining" ,
|
|
648
|
+
64 : "jumpRope" ,
|
|
649
|
+
65 : "kickboxing" ,
|
|
650
|
+
66 : "pilates" ,
|
|
651
|
+
67 : "snowboarding" ,
|
|
652
|
+
68 : "stairs" ,
|
|
653
|
+
69 : "stepTraining" ,
|
|
654
|
+
70 : "wheelchairWalkPace" ,
|
|
655
|
+
71 : "wheelchairRunPace" ,
|
|
656
|
+
72 : "taiChi" ,
|
|
657
|
+
73 : "mixedCardio" ,
|
|
658
|
+
74 : "handCycling" ,
|
|
659
|
+
75 : "discSports" ,
|
|
660
|
+
76 : "fitnessGaming" ,
|
|
661
|
+
77 : "cardioDance" ,
|
|
662
|
+
78 : "socialDance" ,
|
|
663
|
+
79 : "pickleball" ,
|
|
664
|
+
80 : "cooldown" ,
|
|
665
|
+
82 : "swimBikeRun" ,
|
|
666
|
+
83 : "transition" ,
|
|
667
|
+
84 : "underwaterDiving" ,
|
|
668
|
+
3000 : "other"
|
|
669
|
+
]
|
|
670
|
+
|
|
671
|
+
}
|