@interval-health/capacitor-health 1.0.2 → 1.1.0
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/README.md +781 -193
- package/android/src/main/java/app/capgo/plugin/health/HealthDataType.kt +4 -1
- package/dist/docs.json +24 -0
- package/dist/esm/definitions.d.ts +20 -2
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/HealthPlugin/Health.swift +1921 -57
- package/package.json +1 -1
|
@@ -34,6 +34,11 @@ enum HealthDataType: String, CaseIterable {
|
|
|
34
34
|
case heartRate
|
|
35
35
|
case weight
|
|
36
36
|
case sleep
|
|
37
|
+
case mobility
|
|
38
|
+
case activity
|
|
39
|
+
case heart
|
|
40
|
+
case body
|
|
41
|
+
case workout
|
|
37
42
|
|
|
38
43
|
func sampleType() throws -> HKSampleType {
|
|
39
44
|
if self == .sleep {
|
|
@@ -43,6 +48,43 @@ enum HealthDataType: String, CaseIterable {
|
|
|
43
48
|
return type
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
if self == .mobility {
|
|
52
|
+
// Mobility uses multiple types, return walkingSpeed as representative
|
|
53
|
+
guard let type = HKObjectType.quantityType(forIdentifier: .walkingSpeed) else {
|
|
54
|
+
throw HealthManagerError.dataTypeUnavailable(rawValue)
|
|
55
|
+
}
|
|
56
|
+
return type
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if self == .activity {
|
|
60
|
+
// Activity uses multiple types, return stepCount as representative
|
|
61
|
+
guard let type = HKObjectType.quantityType(forIdentifier: .stepCount) else {
|
|
62
|
+
throw HealthManagerError.dataTypeUnavailable(rawValue)
|
|
63
|
+
}
|
|
64
|
+
return type
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if self == .heart {
|
|
68
|
+
// Heart uses multiple types, return heartRate as representative
|
|
69
|
+
guard let type = HKObjectType.quantityType(forIdentifier: .heartRate) else {
|
|
70
|
+
throw HealthManagerError.dataTypeUnavailable(rawValue)
|
|
71
|
+
}
|
|
72
|
+
return type
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if self == .body {
|
|
76
|
+
// Body uses multiple types, return bodyMass as representative
|
|
77
|
+
guard let type = HKObjectType.quantityType(forIdentifier: .bodyMass) else {
|
|
78
|
+
throw HealthManagerError.dataTypeUnavailable(rawValue)
|
|
79
|
+
}
|
|
80
|
+
return type
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if self == .workout {
|
|
84
|
+
// Workout uses HKWorkoutType
|
|
85
|
+
return HKObjectType.workoutType()
|
|
86
|
+
}
|
|
87
|
+
|
|
46
88
|
let identifier: HKQuantityTypeIdentifier
|
|
47
89
|
switch self {
|
|
48
90
|
case .steps:
|
|
@@ -57,6 +99,16 @@ enum HealthDataType: String, CaseIterable {
|
|
|
57
99
|
identifier = .bodyMass
|
|
58
100
|
case .sleep:
|
|
59
101
|
fatalError("Sleep should have been handled above")
|
|
102
|
+
case .mobility:
|
|
103
|
+
fatalError("Mobility should have been handled above")
|
|
104
|
+
case .activity:
|
|
105
|
+
fatalError("Activity should have been handled above")
|
|
106
|
+
case .heart:
|
|
107
|
+
fatalError("Heart should have been handled above")
|
|
108
|
+
case .body:
|
|
109
|
+
fatalError("Body should have been handled above")
|
|
110
|
+
case .workout:
|
|
111
|
+
fatalError("Workout should have been handled above")
|
|
60
112
|
}
|
|
61
113
|
|
|
62
114
|
guard let type = HKObjectType.quantityType(forIdentifier: identifier) else {
|
|
@@ -79,6 +131,16 @@ enum HealthDataType: String, CaseIterable {
|
|
|
79
131
|
return HKUnit.gramUnit(with: .kilo)
|
|
80
132
|
case .sleep:
|
|
81
133
|
return HKUnit.minute() // Sleep duration in minutes
|
|
134
|
+
case .mobility:
|
|
135
|
+
return HKUnit.meter() // Placeholder, mobility has multiple units
|
|
136
|
+
case .activity:
|
|
137
|
+
return HKUnit.count() // Placeholder, activity has multiple units
|
|
138
|
+
case .heart:
|
|
139
|
+
return HKUnit.count().unitDivided(by: HKUnit.minute()) // Placeholder, heart has multiple units
|
|
140
|
+
case .body:
|
|
141
|
+
return HKUnit.gramUnit(with: .kilo) // Placeholder, body has multiple units
|
|
142
|
+
case .workout:
|
|
143
|
+
return HKUnit.minute() // Workout duration in minutes
|
|
82
144
|
}
|
|
83
145
|
}
|
|
84
146
|
|
|
@@ -96,6 +158,16 @@ enum HealthDataType: String, CaseIterable {
|
|
|
96
158
|
return "kilogram"
|
|
97
159
|
case .sleep:
|
|
98
160
|
return "minute"
|
|
161
|
+
case .mobility:
|
|
162
|
+
return "mixed" // Mobility has multiple units
|
|
163
|
+
case .activity:
|
|
164
|
+
return "mixed" // Activity has multiple units
|
|
165
|
+
case .heart:
|
|
166
|
+
return "mixed" // Heart has multiple units
|
|
167
|
+
case .body:
|
|
168
|
+
return "mixed" // Body has multiple units
|
|
169
|
+
case .workout:
|
|
170
|
+
return "minute" // Workout duration in minutes
|
|
99
171
|
}
|
|
100
172
|
}
|
|
101
173
|
|
|
@@ -200,8 +272,7 @@ final class Health {
|
|
|
200
272
|
|
|
201
273
|
func readSamples(dataTypeIdentifier: String, startDateString: String?, endDateString: String?, limit: Int?, ascending: Bool, completion: @escaping (Result<[[String: Any]], Error>) -> Void) throws {
|
|
202
274
|
let dataType = try parseDataType(identifier: dataTypeIdentifier)
|
|
203
|
-
|
|
204
|
-
|
|
275
|
+
|
|
205
276
|
let startDate = try parseDate(startDateString, defaultValue: Date().addingTimeInterval(-86400))
|
|
206
277
|
let endDate = try parseDate(endDateString, defaultValue: Date())
|
|
207
278
|
|
|
@@ -209,6 +280,77 @@ final class Health {
|
|
|
209
280
|
throw HealthManagerError.invalidDateRange
|
|
210
281
|
}
|
|
211
282
|
|
|
283
|
+
// Handle body data (multiple quantity types)
|
|
284
|
+
// Skip the initial query and go directly to processing to avoid authorization issues
|
|
285
|
+
if dataType == .body {
|
|
286
|
+
processBodyData(startDate: startDate, endDate: endDate) { result in
|
|
287
|
+
switch result {
|
|
288
|
+
case .success(let bodyData):
|
|
289
|
+
completion(.success(bodyData))
|
|
290
|
+
case .failure(let error):
|
|
291
|
+
completion(.failure(error))
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Handle heart data (multiple quantity types)
|
|
298
|
+
// Skip the initial query and go directly to processing to avoid authorization issues
|
|
299
|
+
if dataType == .heart {
|
|
300
|
+
processHeartData(startDate: startDate, endDate: endDate) { result in
|
|
301
|
+
switch result {
|
|
302
|
+
case .success(let heartData):
|
|
303
|
+
completion(.success(heartData))
|
|
304
|
+
case .failure(let error):
|
|
305
|
+
completion(.failure(error))
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Handle activity data (multiple quantity and category types)
|
|
312
|
+
// Skip the initial query and go directly to processing to avoid authorization issues
|
|
313
|
+
if dataType == .activity {
|
|
314
|
+
processActivityData(startDate: startDate, endDate: endDate) { result in
|
|
315
|
+
switch result {
|
|
316
|
+
case .success(let activityData):
|
|
317
|
+
completion(.success(activityData))
|
|
318
|
+
case .failure(let error):
|
|
319
|
+
completion(.failure(error))
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Handle mobility data (multiple quantity types)
|
|
326
|
+
// Skip the initial query and go directly to processing to avoid authorization issues
|
|
327
|
+
if dataType == .mobility {
|
|
328
|
+
processMobilityData(startDate: startDate, endDate: endDate) { result in
|
|
329
|
+
switch result {
|
|
330
|
+
case .success(let mobilityData):
|
|
331
|
+
completion(.success(mobilityData))
|
|
332
|
+
case .failure(let error):
|
|
333
|
+
completion(.failure(error))
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Handle workout data
|
|
340
|
+
if dataType == .workout {
|
|
341
|
+
processWorkoutData(startDate: startDate, endDate: endDate, limit: limit, ascending: ascending) { result in
|
|
342
|
+
switch result {
|
|
343
|
+
case .success(let workoutData):
|
|
344
|
+
completion(.success(workoutData))
|
|
345
|
+
case .failure(let error):
|
|
346
|
+
completion(.failure(error))
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// For all other data types, use the standard query approach
|
|
353
|
+
let sampleType = try dataType.sampleType()
|
|
212
354
|
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
213
355
|
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: ascending)
|
|
214
356
|
let queryLimit = limit ?? 100
|
|
@@ -225,7 +367,7 @@ final class Health {
|
|
|
225
367
|
completion(.success([]))
|
|
226
368
|
return
|
|
227
369
|
}
|
|
228
|
-
|
|
370
|
+
|
|
229
371
|
// Handle sleep data (category samples)
|
|
230
372
|
if dataType == .sleep {
|
|
231
373
|
guard let categorySamples = samples as? [HKCategorySample] else {
|
|
@@ -233,27 +375,9 @@ final class Health {
|
|
|
233
375
|
return
|
|
234
376
|
}
|
|
235
377
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
var payload: [String: Any] = [
|
|
241
|
-
"dataType": dataType.rawValue,
|
|
242
|
-
"value": duration,
|
|
243
|
-
"unit": dataType.unitIdentifier,
|
|
244
|
-
"sleepState": sleepValue,
|
|
245
|
-
"startDate": self.isoFormatter.string(from: sample.startDate),
|
|
246
|
-
"endDate": self.isoFormatter.string(from: sample.endDate)
|
|
247
|
-
]
|
|
248
|
-
|
|
249
|
-
let source = sample.sourceRevision.source
|
|
250
|
-
payload["sourceName"] = source.name
|
|
251
|
-
payload["sourceId"] = source.bundleIdentifier
|
|
252
|
-
|
|
253
|
-
return payload
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
completion(.success(results))
|
|
378
|
+
// Process sleep data similar to client-side parser
|
|
379
|
+
let processedSleepData = self.processSleepSamples(categorySamples)
|
|
380
|
+
completion(.success(processedSleepData))
|
|
257
381
|
return
|
|
258
382
|
}
|
|
259
383
|
|
|
@@ -363,18 +487,110 @@ final class Health {
|
|
|
363
487
|
var denied: [HealthDataType] = []
|
|
364
488
|
|
|
365
489
|
for type in types {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
490
|
+
// Special handling for activity - check all activity types
|
|
491
|
+
// Consider authorized if at least one activity type is authorized
|
|
492
|
+
if type == .activity {
|
|
493
|
+
let activityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
494
|
+
.stepCount,
|
|
495
|
+
.distanceWalkingRunning,
|
|
496
|
+
.flightsClimbed,
|
|
497
|
+
.activeEnergyBurned,
|
|
498
|
+
.appleExerciseTime
|
|
499
|
+
]
|
|
500
|
+
|
|
501
|
+
var hasAnyAuthorized = false
|
|
502
|
+
for identifier in activityIdentifiers {
|
|
503
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
504
|
+
let status = healthStore.authorizationStatus(for: quantityType)
|
|
505
|
+
if status == .sharingAuthorized {
|
|
506
|
+
hasAnyAuthorized = true
|
|
507
|
+
break
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Also check stand hour (category type)
|
|
513
|
+
if !hasAnyAuthorized {
|
|
514
|
+
if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
|
|
515
|
+
let status = healthStore.authorizationStatus(for: standHourType)
|
|
516
|
+
if status == .sharingAuthorized {
|
|
517
|
+
hasAnyAuthorized = true
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if hasAnyAuthorized {
|
|
523
|
+
authorized.append(type)
|
|
524
|
+
} else {
|
|
525
|
+
denied.append(type)
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// Special handling for body - check body composition types
|
|
529
|
+
// Consider authorized if at least one body type is authorized
|
|
530
|
+
else if type == .body {
|
|
531
|
+
let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
532
|
+
.bodyMass,
|
|
533
|
+
.bodyFatPercentage
|
|
534
|
+
]
|
|
535
|
+
|
|
536
|
+
var hasAnyAuthorized = false
|
|
537
|
+
for identifier in bodyIdentifiers {
|
|
538
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
539
|
+
let status = healthStore.authorizationStatus(for: quantityType)
|
|
540
|
+
if status == .sharingAuthorized {
|
|
541
|
+
hasAnyAuthorized = true
|
|
542
|
+
break
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if hasAnyAuthorized {
|
|
548
|
+
authorized.append(type)
|
|
549
|
+
} else {
|
|
550
|
+
denied.append(type)
|
|
551
|
+
}
|
|
369
552
|
}
|
|
553
|
+
// Special handling for mobility - check all 6 types
|
|
554
|
+
else if type == .mobility {
|
|
555
|
+
let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
556
|
+
.walkingSpeed,
|
|
557
|
+
.walkingStepLength,
|
|
558
|
+
.walkingAsymmetryPercentage,
|
|
559
|
+
.walkingDoubleSupportPercentage,
|
|
560
|
+
.stairAscentSpeed,
|
|
561
|
+
.sixMinuteWalkTestDistance
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
var allAuthorized = true
|
|
565
|
+
for identifier in mobilityIdentifiers {
|
|
566
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
567
|
+
let status = healthStore.authorizationStatus(for: quantityType)
|
|
568
|
+
if status != .sharingAuthorized {
|
|
569
|
+
allAuthorized = false
|
|
570
|
+
break
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if allAuthorized {
|
|
576
|
+
authorized.append(type)
|
|
577
|
+
} else {
|
|
578
|
+
denied.append(type)
|
|
579
|
+
}
|
|
580
|
+
} else {
|
|
581
|
+
guard let sampleType = try? type.sampleType() else {
|
|
582
|
+
denied.append(type)
|
|
583
|
+
continue
|
|
584
|
+
}
|
|
370
585
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
586
|
+
switch healthStore.authorizationStatus(for: sampleType) {
|
|
587
|
+
case .sharingAuthorized:
|
|
588
|
+
authorized.append(type)
|
|
589
|
+
case .sharingDenied, .notDetermined:
|
|
590
|
+
denied.append(type)
|
|
591
|
+
@unknown default:
|
|
592
|
+
denied.append(type)
|
|
593
|
+
}
|
|
378
594
|
}
|
|
379
595
|
}
|
|
380
596
|
|
|
@@ -394,28 +610,174 @@ final class Health {
|
|
|
394
610
|
var denied: [HealthDataType] = []
|
|
395
611
|
|
|
396
612
|
for type in types {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
613
|
+
// Special handling for activity - check all activity types
|
|
614
|
+
if type == .activity {
|
|
615
|
+
let activityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
616
|
+
.stepCount,
|
|
617
|
+
.distanceWalkingRunning,
|
|
618
|
+
.flightsClimbed,
|
|
619
|
+
.activeEnergyBurned,
|
|
620
|
+
.appleExerciseTime
|
|
621
|
+
]
|
|
622
|
+
|
|
623
|
+
// Check all 5 quantity types + 1 category type
|
|
624
|
+
var activityAuthorizedCount = 0
|
|
625
|
+
let totalActivityTypes = activityIdentifiers.count + 1 // +1 for stand hour
|
|
626
|
+
|
|
627
|
+
// Create a nested group for activity checks
|
|
628
|
+
let activityGroup = DispatchGroup()
|
|
629
|
+
|
|
630
|
+
for identifier in activityIdentifiers {
|
|
631
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
632
|
+
activityGroup.enter()
|
|
633
|
+
let readSet = Set<HKObjectType>([quantityType])
|
|
634
|
+
healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
|
|
635
|
+
defer { activityGroup.leave() }
|
|
636
|
+
|
|
637
|
+
if error == nil && status == .unnecessary {
|
|
638
|
+
lock.lock()
|
|
639
|
+
activityAuthorizedCount += 1
|
|
640
|
+
lock.unlock()
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Check stand hour (category type)
|
|
647
|
+
if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
|
|
648
|
+
activityGroup.enter()
|
|
649
|
+
let readSet = Set<HKObjectType>([standHourType])
|
|
650
|
+
healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
|
|
651
|
+
defer { activityGroup.leave() }
|
|
652
|
+
|
|
653
|
+
if error == nil && status == .unnecessary {
|
|
654
|
+
lock.lock()
|
|
655
|
+
activityAuthorizedCount += 1
|
|
656
|
+
lock.unlock()
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Wait for all activity checks, then determine if activity is authorized
|
|
662
|
+
// Consider authorized if at least one activity type is authorized
|
|
663
|
+
activityGroup.notify(queue: .main) {
|
|
664
|
+
lock.lock()
|
|
665
|
+
if activityAuthorizedCount > 0 {
|
|
666
|
+
authorized.append(type)
|
|
667
|
+
} else {
|
|
668
|
+
denied.append(type)
|
|
669
|
+
}
|
|
670
|
+
lock.unlock()
|
|
671
|
+
}
|
|
400
672
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
673
|
+
// Special handling for body - check body composition types
|
|
674
|
+
else if type == .body {
|
|
675
|
+
let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
676
|
+
.bodyMass,
|
|
677
|
+
.bodyFatPercentage
|
|
678
|
+
]
|
|
679
|
+
|
|
680
|
+
// Check all body types
|
|
681
|
+
var bodyAuthorizedCount = 0
|
|
682
|
+
|
|
683
|
+
// Create a nested group for body checks
|
|
684
|
+
let bodyGroup = DispatchGroup()
|
|
685
|
+
|
|
686
|
+
for identifier in bodyIdentifiers {
|
|
687
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
688
|
+
bodyGroup.enter()
|
|
689
|
+
let readSet = Set<HKObjectType>([quantityType])
|
|
690
|
+
healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
|
|
691
|
+
defer { bodyGroup.leave() }
|
|
692
|
+
|
|
693
|
+
if error == nil && status == .unnecessary {
|
|
694
|
+
lock.lock()
|
|
695
|
+
bodyAuthorizedCount += 1
|
|
696
|
+
lock.unlock()
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Wait for all body checks, then determine if body is authorized
|
|
703
|
+
// Consider authorized if at least one body type is authorized
|
|
704
|
+
bodyGroup.notify(queue: .main) {
|
|
705
|
+
lock.lock()
|
|
706
|
+
if bodyAuthorizedCount > 0 {
|
|
707
|
+
authorized.append(type)
|
|
708
|
+
} else {
|
|
709
|
+
denied.append(type)
|
|
710
|
+
}
|
|
711
|
+
lock.unlock()
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// Special handling for mobility - check all 6 types
|
|
715
|
+
else if type == .mobility {
|
|
716
|
+
let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
717
|
+
.walkingSpeed,
|
|
718
|
+
.walkingStepLength,
|
|
719
|
+
.walkingAsymmetryPercentage,
|
|
720
|
+
.walkingDoubleSupportPercentage,
|
|
721
|
+
.stairAscentSpeed,
|
|
722
|
+
.sixMinuteWalkTestDistance
|
|
723
|
+
]
|
|
724
|
+
|
|
725
|
+
// Check all 6 mobility types
|
|
726
|
+
var mobilityAuthorizedCount = 0
|
|
727
|
+
|
|
728
|
+
// Create a nested group for mobility checks
|
|
729
|
+
let mobilityGroup = DispatchGroup()
|
|
730
|
+
|
|
731
|
+
for identifier in mobilityIdentifiers {
|
|
732
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
733
|
+
mobilityGroup.enter()
|
|
734
|
+
let readSet = Set<HKObjectType>([quantityType])
|
|
735
|
+
healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
|
|
736
|
+
defer { mobilityGroup.leave() }
|
|
737
|
+
|
|
738
|
+
if error == nil && status == .unnecessary {
|
|
739
|
+
lock.lock()
|
|
740
|
+
mobilityAuthorizedCount += 1
|
|
741
|
+
lock.unlock()
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Wait for all mobility checks, then determine if mobility is authorized
|
|
748
|
+
mobilityGroup.notify(queue: .main) {
|
|
749
|
+
lock.lock()
|
|
750
|
+
if mobilityAuthorizedCount == mobilityIdentifiers.count {
|
|
751
|
+
authorized.append(type)
|
|
752
|
+
} else {
|
|
753
|
+
denied.append(type)
|
|
754
|
+
}
|
|
755
|
+
lock.unlock()
|
|
756
|
+
}
|
|
757
|
+
} else {
|
|
758
|
+
guard let objectType = try? type.sampleType() else {
|
|
759
|
+
denied.append(type)
|
|
760
|
+
continue
|
|
410
761
|
}
|
|
411
762
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
763
|
+
group.enter()
|
|
764
|
+
let readSet = Set<HKObjectType>([objectType])
|
|
765
|
+
healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
|
|
766
|
+
defer { group.leave() }
|
|
767
|
+
|
|
768
|
+
if error != nil {
|
|
769
|
+
lock.lock(); denied.append(type); lock.unlock()
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
switch status {
|
|
774
|
+
case .unnecessary:
|
|
775
|
+
lock.lock(); authorized.append(type); lock.unlock()
|
|
776
|
+
case .shouldRequest, .unknown:
|
|
777
|
+
lock.lock(); denied.append(type); lock.unlock()
|
|
778
|
+
@unknown default:
|
|
779
|
+
lock.lock(); denied.append(type); lock.unlock()
|
|
780
|
+
}
|
|
419
781
|
}
|
|
420
782
|
}
|
|
421
783
|
}
|
|
@@ -478,6 +840,225 @@ final class Health {
|
|
|
478
840
|
}
|
|
479
841
|
}
|
|
480
842
|
}
|
|
843
|
+
|
|
844
|
+
private func processSleepSamples(_ samples: [HKCategorySample]) -> [[String: Any]] {
|
|
845
|
+
// Filter for detailed stage data only (Deep, REM, Core, Awake)
|
|
846
|
+
let detailedSamples = samples.filter { sample in
|
|
847
|
+
if #available(iOS 16.0, *) {
|
|
848
|
+
return sample.value == HKCategoryValueSleepAnalysis.asleepDeep.rawValue ||
|
|
849
|
+
sample.value == HKCategoryValueSleepAnalysis.asleepREM.rawValue ||
|
|
850
|
+
sample.value == HKCategoryValueSleepAnalysis.asleepCore.rawValue ||
|
|
851
|
+
sample.value == HKCategoryValueSleepAnalysis.awake.rawValue
|
|
852
|
+
} else {
|
|
853
|
+
// For older iOS, just process what's available
|
|
854
|
+
return true
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Sort by start date
|
|
859
|
+
let sortedSamples = detailedSamples.sorted { $0.startDate < $1.startDate }
|
|
860
|
+
|
|
861
|
+
// Collect ALL stage segments (raw data) - matching TypeScript structure
|
|
862
|
+
struct SleepSegment {
|
|
863
|
+
let start: Date
|
|
864
|
+
let end: Date
|
|
865
|
+
let stage: String
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
var allSegments: [SleepSegment] = []
|
|
869
|
+
|
|
870
|
+
for sample in sortedSamples {
|
|
871
|
+
let stageValue = getHealthKitStageConstantName(for: sample.value)
|
|
872
|
+
allSegments.append(SleepSegment(
|
|
873
|
+
start: sample.startDate,
|
|
874
|
+
end: sample.endDate,
|
|
875
|
+
stage: stageValue
|
|
876
|
+
))
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Group segments by SLEEP SESSION (not by day!)
|
|
880
|
+
// Sessions are separated by 30+ minute gaps
|
|
881
|
+
struct SleepSession {
|
|
882
|
+
let start: Date
|
|
883
|
+
let end: Date
|
|
884
|
+
let segments: [SleepSegment]
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
var sessions: [SleepSession] = []
|
|
888
|
+
var currentSession: [SleepSegment] = []
|
|
889
|
+
|
|
890
|
+
for segment in allSegments {
|
|
891
|
+
if currentSession.isEmpty {
|
|
892
|
+
currentSession.append(segment)
|
|
893
|
+
} else {
|
|
894
|
+
let lastSegment = currentSession.last!
|
|
895
|
+
let gapMinutes = segment.start.timeIntervalSince(lastSegment.end) / 60.0
|
|
896
|
+
|
|
897
|
+
if gapMinutes < 30 {
|
|
898
|
+
// Same session
|
|
899
|
+
currentSession.append(segment)
|
|
900
|
+
} else {
|
|
901
|
+
// New session (gap > 30 min)
|
|
902
|
+
if !currentSession.isEmpty {
|
|
903
|
+
sessions.append(SleepSession(
|
|
904
|
+
start: currentSession.first!.start,
|
|
905
|
+
end: currentSession.last!.end,
|
|
906
|
+
segments: currentSession
|
|
907
|
+
))
|
|
908
|
+
}
|
|
909
|
+
currentSession = [segment]
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Don't forget last session
|
|
915
|
+
if !currentSession.isEmpty {
|
|
916
|
+
sessions.append(SleepSession(
|
|
917
|
+
start: currentSession.first!.start,
|
|
918
|
+
end: currentSession.last!.end,
|
|
919
|
+
segments: currentSession
|
|
920
|
+
))
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Now attribute each session to the day you WOKE UP (end date)
|
|
924
|
+
struct DayData {
|
|
925
|
+
var sessions: [SleepSession]
|
|
926
|
+
var deepMinutes: Double
|
|
927
|
+
var remMinutes: Double
|
|
928
|
+
var coreMinutes: Double
|
|
929
|
+
var awakeMinutes: Double
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
var sleepByDate: [String: DayData] = [:]
|
|
933
|
+
|
|
934
|
+
for session in sessions {
|
|
935
|
+
// Wake-up date (local)
|
|
936
|
+
let calendar = Calendar.current
|
|
937
|
+
let wakeDate = calendar.dateComponents([.year, .month, .day], from: session.end)
|
|
938
|
+
let dateString = String(format: "%04d-%02d-%02d", wakeDate.year!, wakeDate.month!, wakeDate.day!)
|
|
939
|
+
|
|
940
|
+
// Initialize day data if needed
|
|
941
|
+
if sleepByDate[dateString] == nil {
|
|
942
|
+
sleepByDate[dateString] = DayData(
|
|
943
|
+
sessions: [],
|
|
944
|
+
deepMinutes: 0,
|
|
945
|
+
remMinutes: 0,
|
|
946
|
+
coreMinutes: 0,
|
|
947
|
+
awakeMinutes: 0
|
|
948
|
+
)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
sleepByDate[dateString]!.sessions.append(session)
|
|
952
|
+
|
|
953
|
+
// Calculate minutes per stage for THIS session
|
|
954
|
+
for segment in session.segments {
|
|
955
|
+
let minutes = segment.end.timeIntervalSince(segment.start) / 60.0
|
|
956
|
+
|
|
957
|
+
if segment.stage == "HKCategoryValueSleepAnalysisAsleepDeep" {
|
|
958
|
+
sleepByDate[dateString]!.deepMinutes += minutes
|
|
959
|
+
} else if segment.stage == "HKCategoryValueSleepAnalysisAsleepREM" {
|
|
960
|
+
sleepByDate[dateString]!.remMinutes += minutes
|
|
961
|
+
} else if segment.stage == "HKCategoryValueSleepAnalysisAsleepCore" {
|
|
962
|
+
sleepByDate[dateString]!.coreMinutes += minutes
|
|
963
|
+
} else if segment.stage == "HKCategoryValueSleepAnalysisAwake" {
|
|
964
|
+
sleepByDate[dateString]!.awakeMinutes += minutes
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Convert to final format
|
|
970
|
+
var sleepData: [[String: Any]] = []
|
|
971
|
+
|
|
972
|
+
for (date, data) in sleepByDate {
|
|
973
|
+
let deepHours = data.deepMinutes / 60.0
|
|
974
|
+
let remHours = data.remMinutes / 60.0
|
|
975
|
+
let coreHours = data.coreMinutes / 60.0
|
|
976
|
+
let awakeHours = data.awakeMinutes / 60.0
|
|
977
|
+
let totalSleepHours = deepHours + remHours + coreHours
|
|
978
|
+
|
|
979
|
+
// Calculate time in bed from merged sessions (first start to last end)
|
|
980
|
+
var timeInBed = 0.0
|
|
981
|
+
if !data.sessions.isEmpty {
|
|
982
|
+
let bedtime = data.sessions.first!.start
|
|
983
|
+
let wakeTime = data.sessions.last!.end
|
|
984
|
+
timeInBed = wakeTime.timeIntervalSince(bedtime) / 3600.0
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Calculate efficiency
|
|
988
|
+
let efficiency = timeInBed > 0 ? Int(round((totalSleepHours / timeInBed) * 100)) : 0
|
|
989
|
+
|
|
990
|
+
// Map sessions to output format with segments matching TypeScript structure
|
|
991
|
+
let mergedSessions: [[String: Any]] = data.sessions.map { session in
|
|
992
|
+
let segments: [[String: Any]] = session.segments.map { segment in
|
|
993
|
+
return [
|
|
994
|
+
"start": isoFormatter.string(from: segment.start),
|
|
995
|
+
"end": isoFormatter.string(from: segment.end),
|
|
996
|
+
"stage": segment.stage
|
|
997
|
+
]
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
return [
|
|
1001
|
+
"start": isoFormatter.string(from: session.start),
|
|
1002
|
+
"end": isoFormatter.string(from: session.end),
|
|
1003
|
+
"segments": segments
|
|
1004
|
+
]
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
sleepData.append([
|
|
1008
|
+
"date": date,
|
|
1009
|
+
"totalSleepHours": round(totalSleepHours * 10) / 10,
|
|
1010
|
+
"sleepSessions": data.sessions.count,
|
|
1011
|
+
"deepSleep": round(deepHours * 10) / 10,
|
|
1012
|
+
"remSleep": round(remHours * 10) / 10,
|
|
1013
|
+
"coreSleep": round(coreHours * 10) / 10,
|
|
1014
|
+
"awakeTime": round(awakeHours * 10) / 10,
|
|
1015
|
+
"timeInBed": round(timeInBed * 10) / 10,
|
|
1016
|
+
"efficiency": efficiency,
|
|
1017
|
+
"mergedSessions": mergedSessions
|
|
1018
|
+
])
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Sort by date
|
|
1022
|
+
sleepData.sort { (a, b) -> Bool in
|
|
1023
|
+
let dateA = a["date"] as! String
|
|
1024
|
+
let dateB = b["date"] as! String
|
|
1025
|
+
return dateA < dateB
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
return sleepData
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
private func getHealthKitStageConstantName(for value: Int) -> String {
|
|
1032
|
+
if #available(iOS 16.0, *) {
|
|
1033
|
+
switch value {
|
|
1034
|
+
case HKCategoryValueSleepAnalysis.asleepDeep.rawValue:
|
|
1035
|
+
return "HKCategoryValueSleepAnalysisAsleepDeep"
|
|
1036
|
+
case HKCategoryValueSleepAnalysis.asleepREM.rawValue:
|
|
1037
|
+
return "HKCategoryValueSleepAnalysisAsleepREM"
|
|
1038
|
+
case HKCategoryValueSleepAnalysis.asleepCore.rawValue:
|
|
1039
|
+
return "HKCategoryValueSleepAnalysisAsleepCore"
|
|
1040
|
+
case HKCategoryValueSleepAnalysis.awake.rawValue:
|
|
1041
|
+
return "HKCategoryValueSleepAnalysisAwake"
|
|
1042
|
+
case HKCategoryValueSleepAnalysis.inBed.rawValue:
|
|
1043
|
+
return "HKCategoryValueSleepAnalysisInBed"
|
|
1044
|
+
case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue:
|
|
1045
|
+
return "HKCategoryValueSleepAnalysisAsleepUnspecified"
|
|
1046
|
+
default:
|
|
1047
|
+
return "HKCategoryValueSleepAnalysisUnknown"
|
|
1048
|
+
}
|
|
1049
|
+
} else {
|
|
1050
|
+
switch value {
|
|
1051
|
+
case HKCategoryValueSleepAnalysis.inBed.rawValue:
|
|
1052
|
+
return "HKCategoryValueSleepAnalysisInBed"
|
|
1053
|
+
case HKCategoryValueSleepAnalysis.asleep.rawValue:
|
|
1054
|
+
return "HKCategoryValueSleepAnalysisAsleep"
|
|
1055
|
+
case HKCategoryValueSleepAnalysis.awake.rawValue:
|
|
1056
|
+
return "HKCategoryValueSleepAnalysisAwake"
|
|
1057
|
+
default:
|
|
1058
|
+
return "HKCategoryValueSleepAnalysisUnknown"
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
481
1062
|
|
|
482
1063
|
private func unit(for identifier: String?, dataType: HealthDataType) -> HKUnit {
|
|
483
1064
|
guard let identifier = identifier else {
|
|
@@ -505,8 +1086,77 @@ final class Health {
|
|
|
505
1086
|
private func objectTypes(for dataTypes: [HealthDataType]) throws -> Set<HKObjectType> {
|
|
506
1087
|
var set = Set<HKObjectType>()
|
|
507
1088
|
for dataType in dataTypes {
|
|
508
|
-
|
|
509
|
-
|
|
1089
|
+
// Special handling for activity - expand into all activity-related HealthKit types
|
|
1090
|
+
if dataType == .activity {
|
|
1091
|
+
let activityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1092
|
+
.stepCount,
|
|
1093
|
+
.distanceWalkingRunning,
|
|
1094
|
+
.flightsClimbed,
|
|
1095
|
+
.activeEnergyBurned,
|
|
1096
|
+
.appleExerciseTime
|
|
1097
|
+
]
|
|
1098
|
+
|
|
1099
|
+
for identifier in activityIdentifiers {
|
|
1100
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1101
|
+
set.insert(quantityType)
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Add category type for stand hours
|
|
1106
|
+
if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
|
|
1107
|
+
set.insert(standHourType)
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
// Special handling for heart - expand into all 6 HealthKit types
|
|
1111
|
+
else if dataType == .heart {
|
|
1112
|
+
let heartIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1113
|
+
.heartRate,
|
|
1114
|
+
.restingHeartRate,
|
|
1115
|
+
.vo2Max,
|
|
1116
|
+
.heartRateVariabilitySDNN,
|
|
1117
|
+
.oxygenSaturation,
|
|
1118
|
+
.respiratoryRate
|
|
1119
|
+
]
|
|
1120
|
+
|
|
1121
|
+
for identifier in heartIdentifiers {
|
|
1122
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1123
|
+
set.insert(quantityType)
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
// Special handling for body - expand into body composition types
|
|
1128
|
+
else if dataType == .body {
|
|
1129
|
+
let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1130
|
+
.bodyMass,
|
|
1131
|
+
.bodyFatPercentage
|
|
1132
|
+
]
|
|
1133
|
+
|
|
1134
|
+
for identifier in bodyIdentifiers {
|
|
1135
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1136
|
+
set.insert(quantityType)
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
// Special handling for mobility - expand into all 6 HealthKit types
|
|
1141
|
+
else if dataType == .mobility {
|
|
1142
|
+
let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1143
|
+
.walkingSpeed,
|
|
1144
|
+
.walkingStepLength,
|
|
1145
|
+
.walkingAsymmetryPercentage,
|
|
1146
|
+
.walkingDoubleSupportPercentage,
|
|
1147
|
+
.stairAscentSpeed,
|
|
1148
|
+
.sixMinuteWalkTestDistance
|
|
1149
|
+
]
|
|
1150
|
+
|
|
1151
|
+
for identifier in mobilityIdentifiers {
|
|
1152
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1153
|
+
set.insert(quantityType)
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
} else {
|
|
1157
|
+
let type = try dataType.sampleType()
|
|
1158
|
+
set.insert(type)
|
|
1159
|
+
}
|
|
510
1160
|
}
|
|
511
1161
|
return set
|
|
512
1162
|
}
|
|
@@ -514,9 +1164,1223 @@ final class Health {
|
|
|
514
1164
|
private func sampleTypes(for dataTypes: [HealthDataType]) throws -> Set<HKSampleType> {
|
|
515
1165
|
var set = Set<HKSampleType>()
|
|
516
1166
|
for dataType in dataTypes {
|
|
517
|
-
|
|
518
|
-
|
|
1167
|
+
// Special handling for activity - expand into all activity-related HealthKit types
|
|
1168
|
+
if dataType == .activity {
|
|
1169
|
+
let activityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1170
|
+
.stepCount,
|
|
1171
|
+
.distanceWalkingRunning,
|
|
1172
|
+
.flightsClimbed,
|
|
1173
|
+
.activeEnergyBurned,
|
|
1174
|
+
.appleExerciseTime
|
|
1175
|
+
]
|
|
1176
|
+
|
|
1177
|
+
for identifier in activityIdentifiers {
|
|
1178
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1179
|
+
set.insert(quantityType)
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Add category type for stand hours
|
|
1184
|
+
if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
|
|
1185
|
+
set.insert(standHourType)
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
// Special handling for heart - expand into all 6 HealthKit types
|
|
1189
|
+
else if dataType == .heart {
|
|
1190
|
+
let heartIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1191
|
+
.heartRate,
|
|
1192
|
+
.restingHeartRate,
|
|
1193
|
+
.vo2Max,
|
|
1194
|
+
.heartRateVariabilitySDNN,
|
|
1195
|
+
.oxygenSaturation,
|
|
1196
|
+
.respiratoryRate
|
|
1197
|
+
]
|
|
1198
|
+
|
|
1199
|
+
for identifier in heartIdentifiers {
|
|
1200
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1201
|
+
set.insert(quantityType)
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
// Special handling for body - expand into body composition types
|
|
1206
|
+
else if dataType == .body {
|
|
1207
|
+
let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1208
|
+
.bodyMass,
|
|
1209
|
+
.bodyFatPercentage
|
|
1210
|
+
]
|
|
1211
|
+
|
|
1212
|
+
for identifier in bodyIdentifiers {
|
|
1213
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1214
|
+
set.insert(quantityType)
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
// Special handling for mobility - expand into all 6 HealthKit types
|
|
1219
|
+
else if dataType == .mobility {
|
|
1220
|
+
let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1221
|
+
.walkingSpeed,
|
|
1222
|
+
.walkingStepLength,
|
|
1223
|
+
.walkingAsymmetryPercentage,
|
|
1224
|
+
.walkingDoubleSupportPercentage,
|
|
1225
|
+
.stairAscentSpeed,
|
|
1226
|
+
.sixMinuteWalkTestDistance
|
|
1227
|
+
]
|
|
1228
|
+
|
|
1229
|
+
for identifier in mobilityIdentifiers {
|
|
1230
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1231
|
+
set.insert(quantityType)
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
} else {
|
|
1235
|
+
let type = try dataType.sampleType() as HKSampleType
|
|
1236
|
+
set.insert(type)
|
|
1237
|
+
}
|
|
519
1238
|
}
|
|
520
1239
|
return set
|
|
521
1240
|
}
|
|
1241
|
+
|
|
1242
|
+
// MARK: - Body Data Processing
|
|
1243
|
+
|
|
1244
|
+
private func processBodyData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
|
|
1245
|
+
let calendar = Calendar.current
|
|
1246
|
+
|
|
1247
|
+
// Define body composition quantity types
|
|
1248
|
+
let bodyTypes: [(HKQuantityTypeIdentifier, String)] = [
|
|
1249
|
+
(.bodyMass, "bodyMass"),
|
|
1250
|
+
(.bodyFatPercentage, "bodyFatPercentage")
|
|
1251
|
+
]
|
|
1252
|
+
|
|
1253
|
+
// Get all dates in the range
|
|
1254
|
+
var dates: [Date] = []
|
|
1255
|
+
var currentDate = calendar.startOfDay(for: startDate)
|
|
1256
|
+
let endOfDay = calendar.startOfDay(for: endDate)
|
|
1257
|
+
|
|
1258
|
+
while currentDate <= endOfDay {
|
|
1259
|
+
dates.append(currentDate)
|
|
1260
|
+
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// Dictionary to store body data by date
|
|
1264
|
+
var bodyDataByDate: [String: [String: Any]] = [:]
|
|
1265
|
+
|
|
1266
|
+
// Initialize dates with empty dictionaries
|
|
1267
|
+
let dateFormatter = ISO8601DateFormatter()
|
|
1268
|
+
dateFormatter.formatOptions = [.withFullDate]
|
|
1269
|
+
|
|
1270
|
+
for date in dates {
|
|
1271
|
+
let dateStr = dateFormatter.string(from: date)
|
|
1272
|
+
bodyDataByDate[dateStr] = ["date": dateStr]
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
let group = DispatchGroup()
|
|
1276
|
+
|
|
1277
|
+
// Query each body type
|
|
1278
|
+
for (identifier, _) in bodyTypes {
|
|
1279
|
+
guard let quantityType = HKObjectType.quantityType(forIdentifier: identifier) else {
|
|
1280
|
+
continue
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
group.enter()
|
|
1284
|
+
|
|
1285
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
|
1286
|
+
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
|
1287
|
+
|
|
1288
|
+
let query = HKSampleQuery(sampleType: quantityType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sortDescriptor]) { _, samples, error in
|
|
1289
|
+
defer { group.leave() }
|
|
1290
|
+
|
|
1291
|
+
// Don't fail the entire request if one body type fails
|
|
1292
|
+
// Just skip this type and continue with others
|
|
1293
|
+
if error != nil {
|
|
1294
|
+
return
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
guard let samples = samples as? [HKQuantitySample] else {
|
|
1298
|
+
return
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// Group samples by date and take the last measurement of each day
|
|
1302
|
+
var samplesByDate: [String: HKQuantitySample] = [:]
|
|
1303
|
+
|
|
1304
|
+
for sample in samples {
|
|
1305
|
+
let sampleDate = calendar.startOfDay(for: sample.startDate)
|
|
1306
|
+
let dateStr = dateFormatter.string(from: sampleDate)
|
|
1307
|
+
|
|
1308
|
+
// Keep the last (most recent) sample for each date
|
|
1309
|
+
if let existingSample = samplesByDate[dateStr] {
|
|
1310
|
+
if sample.startDate > existingSample.startDate {
|
|
1311
|
+
samplesByDate[dateStr] = sample
|
|
1312
|
+
}
|
|
1313
|
+
} else {
|
|
1314
|
+
samplesByDate[dateStr] = sample
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// Process the last measurement for each date
|
|
1319
|
+
for (dateStr, sample) in samplesByDate {
|
|
1320
|
+
var dayData = bodyDataByDate[dateStr] ?? ["date": dateStr]
|
|
1321
|
+
|
|
1322
|
+
switch identifier {
|
|
1323
|
+
case .bodyMass:
|
|
1324
|
+
// Convert kg to lbs: kg * 2.20462
|
|
1325
|
+
let valueInKg = sample.quantity.doubleValue(for: HKUnit.gramUnit(with: .kilo))
|
|
1326
|
+
let valueInLbs = valueInKg * 2.20462
|
|
1327
|
+
dayData["weight"] = round(valueInLbs * 10) / 10 // 1 decimal place
|
|
1328
|
+
|
|
1329
|
+
case .bodyFatPercentage:
|
|
1330
|
+
// Convert decimal to percentage: 0.25 -> 25%
|
|
1331
|
+
let valueInDecimal = sample.quantity.doubleValue(for: HKUnit.percent())
|
|
1332
|
+
dayData["bodyFat"] = round(valueInDecimal * 10) / 10 // 1 decimal place
|
|
1333
|
+
|
|
1334
|
+
default:
|
|
1335
|
+
break
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
bodyDataByDate[dateStr] = dayData
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
healthStore.execute(query)
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
group.notify(queue: .main) {
|
|
1346
|
+
// Convert to array and sort by date
|
|
1347
|
+
let sortedData = bodyDataByDate.values
|
|
1348
|
+
.sorted { dict1, dict2 in
|
|
1349
|
+
guard let date1Str = dict1["date"] as? String,
|
|
1350
|
+
let date2Str = dict2["date"] as? String else {
|
|
1351
|
+
return false
|
|
1352
|
+
}
|
|
1353
|
+
return date1Str < date2Str
|
|
1354
|
+
}
|
|
1355
|
+
.filter { dict in
|
|
1356
|
+
// Only include dates that have at least one body measurement
|
|
1357
|
+
return dict.count > 1 // More than just the "date" field
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
completion(.success(sortedData))
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// MARK: - Mobility Data Processing
|
|
1365
|
+
|
|
1366
|
+
private func processMobilityData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
|
|
1367
|
+
let calendar = Calendar.current
|
|
1368
|
+
|
|
1369
|
+
// Define all mobility quantity types
|
|
1370
|
+
let mobilityTypes: [(HKQuantityTypeIdentifier, String)] = [
|
|
1371
|
+
(.walkingSpeed, "walkingSpeed"),
|
|
1372
|
+
(.walkingStepLength, "walkingStepLength"),
|
|
1373
|
+
(.walkingAsymmetryPercentage, "walkingAsymmetry"),
|
|
1374
|
+
(.walkingDoubleSupportPercentage, "walkingDoubleSupportTime"),
|
|
1375
|
+
(.stairAscentSpeed, "stairSpeed"),
|
|
1376
|
+
(.sixMinuteWalkTestDistance, "sixMinuteWalkDistance")
|
|
1377
|
+
]
|
|
1378
|
+
|
|
1379
|
+
let group = DispatchGroup()
|
|
1380
|
+
let lock = NSLock()
|
|
1381
|
+
|
|
1382
|
+
// Maps to store values by date for each metric
|
|
1383
|
+
var walkingSpeedMap: [String: [Double]] = [:]
|
|
1384
|
+
var stepLengthMap: [String: [Double]] = [:]
|
|
1385
|
+
var asymmetryMap: [String: [Double]] = [:]
|
|
1386
|
+
var doubleSupportMap: [String: [Double]] = [:]
|
|
1387
|
+
var stairSpeedMap: [String: [Double]] = [:]
|
|
1388
|
+
var sixMinWalkMap: [String: [Double]] = [:]
|
|
1389
|
+
|
|
1390
|
+
var errors: [Error] = []
|
|
1391
|
+
|
|
1392
|
+
// Query each mobility metric
|
|
1393
|
+
for (typeIdentifier, key) in mobilityTypes {
|
|
1394
|
+
guard let quantityType = HKObjectType.quantityType(forIdentifier: typeIdentifier) else {
|
|
1395
|
+
continue
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
group.enter()
|
|
1399
|
+
|
|
1400
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1401
|
+
let query = HKSampleQuery(sampleType: quantityType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1402
|
+
defer { group.leave() }
|
|
1403
|
+
|
|
1404
|
+
if let error = error {
|
|
1405
|
+
lock.lock()
|
|
1406
|
+
errors.append(error)
|
|
1407
|
+
lock.unlock()
|
|
1408
|
+
return
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else {
|
|
1412
|
+
return
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// Process samples and group by date
|
|
1416
|
+
for sample in quantitySamples {
|
|
1417
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1418
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1419
|
+
|
|
1420
|
+
var value: Double = 0.0
|
|
1421
|
+
|
|
1422
|
+
// Convert values to match XML parser units
|
|
1423
|
+
switch typeIdentifier {
|
|
1424
|
+
case .walkingSpeed:
|
|
1425
|
+
// Convert to mph (HealthKit stores in m/s)
|
|
1426
|
+
value = sample.quantity.doubleValue(for: HKUnit.meter().unitDivided(by: HKUnit.second())) * 2.23694
|
|
1427
|
+
lock.lock()
|
|
1428
|
+
if walkingSpeedMap[dateString] == nil {
|
|
1429
|
+
walkingSpeedMap[dateString] = []
|
|
1430
|
+
}
|
|
1431
|
+
walkingSpeedMap[dateString]?.append(value)
|
|
1432
|
+
lock.unlock()
|
|
1433
|
+
|
|
1434
|
+
case .walkingStepLength:
|
|
1435
|
+
// Convert to inches (HealthKit stores in meters)
|
|
1436
|
+
value = sample.quantity.doubleValue(for: HKUnit.meter()) * 39.3701
|
|
1437
|
+
lock.lock()
|
|
1438
|
+
if stepLengthMap[dateString] == nil {
|
|
1439
|
+
stepLengthMap[dateString] = []
|
|
1440
|
+
}
|
|
1441
|
+
stepLengthMap[dateString]?.append(value)
|
|
1442
|
+
lock.unlock()
|
|
1443
|
+
|
|
1444
|
+
case .walkingAsymmetryPercentage:
|
|
1445
|
+
// Convert to percentage (HealthKit stores as decimal)
|
|
1446
|
+
value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
|
|
1447
|
+
lock.lock()
|
|
1448
|
+
if asymmetryMap[dateString] == nil {
|
|
1449
|
+
asymmetryMap[dateString] = []
|
|
1450
|
+
}
|
|
1451
|
+
asymmetryMap[dateString]?.append(value)
|
|
1452
|
+
lock.unlock()
|
|
1453
|
+
|
|
1454
|
+
case .walkingDoubleSupportPercentage:
|
|
1455
|
+
// Convert to percentage (HealthKit stores as decimal)
|
|
1456
|
+
value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
|
|
1457
|
+
lock.lock()
|
|
1458
|
+
if doubleSupportMap[dateString] == nil {
|
|
1459
|
+
doubleSupportMap[dateString] = []
|
|
1460
|
+
}
|
|
1461
|
+
doubleSupportMap[dateString]?.append(value)
|
|
1462
|
+
lock.unlock()
|
|
1463
|
+
|
|
1464
|
+
case .stairAscentSpeed:
|
|
1465
|
+
// Convert to ft/s (HealthKit stores in m/s)
|
|
1466
|
+
value = sample.quantity.doubleValue(for: HKUnit.meter().unitDivided(by: HKUnit.second())) * 3.28084
|
|
1467
|
+
lock.lock()
|
|
1468
|
+
if stairSpeedMap[dateString] == nil {
|
|
1469
|
+
stairSpeedMap[dateString] = []
|
|
1470
|
+
}
|
|
1471
|
+
stairSpeedMap[dateString]?.append(value)
|
|
1472
|
+
lock.unlock()
|
|
1473
|
+
|
|
1474
|
+
case .sixMinuteWalkTestDistance:
|
|
1475
|
+
// Convert to yards (HealthKit stores in meters)
|
|
1476
|
+
value = sample.quantity.doubleValue(for: HKUnit.meter()) * 1.09361
|
|
1477
|
+
lock.lock()
|
|
1478
|
+
if sixMinWalkMap[dateString] == nil {
|
|
1479
|
+
sixMinWalkMap[dateString] = []
|
|
1480
|
+
}
|
|
1481
|
+
sixMinWalkMap[dateString]?.append(value)
|
|
1482
|
+
lock.unlock()
|
|
1483
|
+
|
|
1484
|
+
default:
|
|
1485
|
+
break
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
healthStore.execute(query)
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
group.notify(queue: .main) {
|
|
1494
|
+
if !errors.isEmpty {
|
|
1495
|
+
completion(.failure(errors.first!))
|
|
1496
|
+
return
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// Collect all unique dates
|
|
1500
|
+
var allDates = Set<String>()
|
|
1501
|
+
allDates.formUnion(walkingSpeedMap.keys)
|
|
1502
|
+
allDates.formUnion(stepLengthMap.keys)
|
|
1503
|
+
allDates.formUnion(asymmetryMap.keys)
|
|
1504
|
+
allDates.formUnion(doubleSupportMap.keys)
|
|
1505
|
+
allDates.formUnion(stairSpeedMap.keys)
|
|
1506
|
+
allDates.formUnion(sixMinWalkMap.keys)
|
|
1507
|
+
|
|
1508
|
+
// Create mobility data array with aggregated daily averages
|
|
1509
|
+
var mobilityData: [[String: Any]] = []
|
|
1510
|
+
|
|
1511
|
+
for date in allDates.sorted() {
|
|
1512
|
+
var result: [String: Any] = ["date": date]
|
|
1513
|
+
|
|
1514
|
+
// Average all measurements for the day
|
|
1515
|
+
if let values = walkingSpeedMap[date], !values.isEmpty {
|
|
1516
|
+
let average = values.reduce(0.0, +) / Double(values.count)
|
|
1517
|
+
result["walkingSpeed"] = round(average * 100) / 100
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
if let values = stepLengthMap[date], !values.isEmpty {
|
|
1521
|
+
let average = values.reduce(0.0, +) / Double(values.count)
|
|
1522
|
+
result["walkingStepLength"] = round(average * 10) / 10
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
if let values = asymmetryMap[date], !values.isEmpty {
|
|
1526
|
+
let average = values.reduce(0.0, +) / Double(values.count)
|
|
1527
|
+
result["walkingAsymmetry"] = round(average * 10) / 10
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
if let values = doubleSupportMap[date], !values.isEmpty {
|
|
1531
|
+
let average = values.reduce(0.0, +) / Double(values.count)
|
|
1532
|
+
result["walkingDoubleSupportTime"] = round(average * 10) / 10
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
if let values = stairSpeedMap[date], !values.isEmpty {
|
|
1536
|
+
let average = values.reduce(0.0, +) / Double(values.count)
|
|
1537
|
+
result["stairSpeed"] = round(average * 100) / 100
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
if let values = sixMinWalkMap[date], !values.isEmpty {
|
|
1541
|
+
let average = values.reduce(0.0, +) / Double(values.count)
|
|
1542
|
+
result["sixMinuteWalkDistance"] = round(average * 10) / 10
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
mobilityData.append(result)
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
completion(.success(mobilityData))
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// MARK: - Activity Data Processing
|
|
1553
|
+
|
|
1554
|
+
private func processActivityData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
|
|
1555
|
+
let calendar = Calendar.current
|
|
1556
|
+
let group = DispatchGroup()
|
|
1557
|
+
let lock = NSLock()
|
|
1558
|
+
|
|
1559
|
+
// Maps to store values by date for each metric
|
|
1560
|
+
var stepsMap: [String: Double] = [:]
|
|
1561
|
+
var distanceMap: [String: Double] = [:]
|
|
1562
|
+
var flightsMap: [String: Double] = [:]
|
|
1563
|
+
var activeEnergyMap: [String: Double] = [:]
|
|
1564
|
+
var exerciseMinutesMap: [String: Double] = [:]
|
|
1565
|
+
var standHoursMap: [String: Int] = [:]
|
|
1566
|
+
|
|
1567
|
+
var errors: [Error] = []
|
|
1568
|
+
|
|
1569
|
+
// === STEPS ===
|
|
1570
|
+
if let stepsType = HKObjectType.quantityType(forIdentifier: .stepCount) {
|
|
1571
|
+
group.enter()
|
|
1572
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1573
|
+
let query = HKSampleQuery(sampleType: stepsType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1574
|
+
defer { group.leave() }
|
|
1575
|
+
|
|
1576
|
+
if let error = error {
|
|
1577
|
+
lock.lock()
|
|
1578
|
+
errors.append(error)
|
|
1579
|
+
lock.unlock()
|
|
1580
|
+
return
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1584
|
+
|
|
1585
|
+
for sample in quantitySamples {
|
|
1586
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1587
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1588
|
+
|
|
1589
|
+
let steps = sample.quantity.doubleValue(for: HKUnit.count())
|
|
1590
|
+
lock.lock()
|
|
1591
|
+
stepsMap[dateString] = (stepsMap[dateString] ?? 0) + steps
|
|
1592
|
+
lock.unlock()
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
healthStore.execute(query)
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// === DISTANCE (Walking + Running) ===
|
|
1599
|
+
if let distanceType = HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning) {
|
|
1600
|
+
group.enter()
|
|
1601
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1602
|
+
let query = HKSampleQuery(sampleType: distanceType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1603
|
+
defer { group.leave() }
|
|
1604
|
+
|
|
1605
|
+
if let error = error {
|
|
1606
|
+
lock.lock()
|
|
1607
|
+
errors.append(error)
|
|
1608
|
+
lock.unlock()
|
|
1609
|
+
return
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1613
|
+
|
|
1614
|
+
for sample in quantitySamples {
|
|
1615
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1616
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1617
|
+
|
|
1618
|
+
// Convert to miles (HealthKit stores in meters)
|
|
1619
|
+
let distanceMeters = sample.quantity.doubleValue(for: HKUnit.meter())
|
|
1620
|
+
let distanceMiles = distanceMeters * 0.000621371
|
|
1621
|
+
lock.lock()
|
|
1622
|
+
distanceMap[dateString] = (distanceMap[dateString] ?? 0) + distanceMiles
|
|
1623
|
+
lock.unlock()
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
healthStore.execute(query)
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// === FLIGHTS CLIMBED ===
|
|
1630
|
+
if let flightsType = HKObjectType.quantityType(forIdentifier: .flightsClimbed) {
|
|
1631
|
+
group.enter()
|
|
1632
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1633
|
+
let query = HKSampleQuery(sampleType: flightsType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1634
|
+
defer { group.leave() }
|
|
1635
|
+
|
|
1636
|
+
if let error = error {
|
|
1637
|
+
lock.lock()
|
|
1638
|
+
errors.append(error)
|
|
1639
|
+
lock.unlock()
|
|
1640
|
+
return
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1644
|
+
|
|
1645
|
+
for sample in quantitySamples {
|
|
1646
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1647
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1648
|
+
|
|
1649
|
+
let flights = sample.quantity.doubleValue(for: HKUnit.count())
|
|
1650
|
+
lock.lock()
|
|
1651
|
+
flightsMap[dateString] = (flightsMap[dateString] ?? 0) + flights
|
|
1652
|
+
lock.unlock()
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
healthStore.execute(query)
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// === ACTIVE ENERGY ===
|
|
1659
|
+
if let activeEnergyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned) {
|
|
1660
|
+
group.enter()
|
|
1661
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1662
|
+
let query = HKSampleQuery(sampleType: activeEnergyType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1663
|
+
defer { group.leave() }
|
|
1664
|
+
|
|
1665
|
+
if let error = error {
|
|
1666
|
+
lock.lock()
|
|
1667
|
+
errors.append(error)
|
|
1668
|
+
lock.unlock()
|
|
1669
|
+
return
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1673
|
+
|
|
1674
|
+
for sample in quantitySamples {
|
|
1675
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1676
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1677
|
+
|
|
1678
|
+
let calories = sample.quantity.doubleValue(for: HKUnit.kilocalorie())
|
|
1679
|
+
lock.lock()
|
|
1680
|
+
activeEnergyMap[dateString] = (activeEnergyMap[dateString] ?? 0) + calories
|
|
1681
|
+
lock.unlock()
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
healthStore.execute(query)
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// === EXERCISE MINUTES ===
|
|
1688
|
+
if let exerciseType = HKObjectType.quantityType(forIdentifier: .appleExerciseTime) {
|
|
1689
|
+
group.enter()
|
|
1690
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1691
|
+
let query = HKSampleQuery(sampleType: exerciseType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1692
|
+
defer { group.leave() }
|
|
1693
|
+
|
|
1694
|
+
if let error = error {
|
|
1695
|
+
lock.lock()
|
|
1696
|
+
errors.append(error)
|
|
1697
|
+
lock.unlock()
|
|
1698
|
+
return
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1702
|
+
|
|
1703
|
+
for sample in quantitySamples {
|
|
1704
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1705
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1706
|
+
|
|
1707
|
+
let minutes = sample.quantity.doubleValue(for: HKUnit.minute())
|
|
1708
|
+
lock.lock()
|
|
1709
|
+
exerciseMinutesMap[dateString] = (exerciseMinutesMap[dateString] ?? 0) + minutes
|
|
1710
|
+
lock.unlock()
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
healthStore.execute(query)
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// === STAND HOURS ===
|
|
1717
|
+
if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
|
|
1718
|
+
group.enter()
|
|
1719
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1720
|
+
let query = HKSampleQuery(sampleType: standHourType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1721
|
+
defer { group.leave() }
|
|
1722
|
+
|
|
1723
|
+
if let error = error {
|
|
1724
|
+
lock.lock()
|
|
1725
|
+
errors.append(error)
|
|
1726
|
+
lock.unlock()
|
|
1727
|
+
return
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
guard let categorySamples = samples as? [HKCategorySample] else { return }
|
|
1731
|
+
|
|
1732
|
+
for sample in categorySamples {
|
|
1733
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1734
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1735
|
+
|
|
1736
|
+
// Value is HKCategoryValueAppleStandHour.stood (1) or .idle (0)
|
|
1737
|
+
if sample.value == HKCategoryValueAppleStandHour.stood.rawValue {
|
|
1738
|
+
lock.lock()
|
|
1739
|
+
standHoursMap[dateString] = (standHoursMap[dateString] ?? 0) + 1
|
|
1740
|
+
lock.unlock()
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
healthStore.execute(query)
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
group.notify(queue: .main) {
|
|
1748
|
+
// Only fail if there are errors AND no data was collected
|
|
1749
|
+
// This allows partial data to be returned if some permissions are denied
|
|
1750
|
+
if !errors.isEmpty {
|
|
1751
|
+
// Check if any data was actually collected
|
|
1752
|
+
let hasData = !stepsMap.isEmpty || !distanceMap.isEmpty || !flightsMap.isEmpty ||
|
|
1753
|
+
!activeEnergyMap.isEmpty || !exerciseMinutesMap.isEmpty || !standHoursMap.isEmpty
|
|
1754
|
+
|
|
1755
|
+
if !hasData {
|
|
1756
|
+
// No data at all, return the error
|
|
1757
|
+
completion(.failure(errors.first!))
|
|
1758
|
+
return
|
|
1759
|
+
}
|
|
1760
|
+
// If we have some data, continue and return what we have
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// Collect all unique dates that have any activity data
|
|
1764
|
+
var allDates = Set<String>()
|
|
1765
|
+
allDates.formUnion(stepsMap.keys)
|
|
1766
|
+
allDates.formUnion(distanceMap.keys)
|
|
1767
|
+
allDates.formUnion(flightsMap.keys)
|
|
1768
|
+
allDates.formUnion(activeEnergyMap.keys)
|
|
1769
|
+
allDates.formUnion(exerciseMinutesMap.keys)
|
|
1770
|
+
allDates.formUnion(standHoursMap.keys)
|
|
1771
|
+
|
|
1772
|
+
// Create activity data array matching the reference format
|
|
1773
|
+
let activityData: [[String: Any]] = allDates.sorted().compactMap { date in
|
|
1774
|
+
var result: [String: Any] = ["date": date]
|
|
1775
|
+
|
|
1776
|
+
// Add each metric if available, with proper rounding
|
|
1777
|
+
if let steps = stepsMap[date] {
|
|
1778
|
+
result["steps"] = Int(round(steps))
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
if let distance = distanceMap[date] {
|
|
1782
|
+
result["distance"] = round(distance * 100) / 100
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
if let flights = flightsMap[date] {
|
|
1786
|
+
result["flightsClimbed"] = Int(round(flights))
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
if let activeEnergy = activeEnergyMap[date] {
|
|
1790
|
+
result["activeEnergy"] = Int(round(activeEnergy))
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
if let exerciseMinutes = exerciseMinutesMap[date] {
|
|
1794
|
+
result["exerciseMinutes"] = Int(round(exerciseMinutes))
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
if let standHours = standHoursMap[date] {
|
|
1798
|
+
result["standHours"] = standHours
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
return result
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
completion(.success(activityData))
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// MARK: - Heart Data Processing
|
|
1809
|
+
|
|
1810
|
+
private func processHeartData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
|
|
1811
|
+
let calendar = Calendar.current
|
|
1812
|
+
let group = DispatchGroup()
|
|
1813
|
+
let lock = NSLock()
|
|
1814
|
+
|
|
1815
|
+
// Maps to store values by date for each metric
|
|
1816
|
+
struct HeartRateStats {
|
|
1817
|
+
var sum: Double = 0
|
|
1818
|
+
var count: Int = 0
|
|
1819
|
+
var min: Double = 999
|
|
1820
|
+
var max: Double = 0
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
var heartRateMap: [String: HeartRateStats] = [:]
|
|
1824
|
+
var restingHRMap: [String: Double] = [:]
|
|
1825
|
+
var vo2MaxMap: [String: Double] = [:]
|
|
1826
|
+
var hrvMap: [String: Double] = [:]
|
|
1827
|
+
var spo2Map: [String: [Double]] = [:]
|
|
1828
|
+
var respirationRateMap: [String: [Double]] = [:]
|
|
1829
|
+
|
|
1830
|
+
var errors: [Error] = []
|
|
1831
|
+
|
|
1832
|
+
// === HEART RATE ===
|
|
1833
|
+
if let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) {
|
|
1834
|
+
group.enter()
|
|
1835
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1836
|
+
let query = HKSampleQuery(sampleType: heartRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1837
|
+
defer { group.leave() }
|
|
1838
|
+
|
|
1839
|
+
if let error = error {
|
|
1840
|
+
lock.lock()
|
|
1841
|
+
errors.append(error)
|
|
1842
|
+
lock.unlock()
|
|
1843
|
+
return
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1847
|
+
|
|
1848
|
+
for sample in quantitySamples {
|
|
1849
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1850
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1851
|
+
|
|
1852
|
+
let bpm = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
|
|
1853
|
+
lock.lock()
|
|
1854
|
+
var stats = heartRateMap[dateString] ?? HeartRateStats()
|
|
1855
|
+
stats.sum += bpm
|
|
1856
|
+
stats.count += 1
|
|
1857
|
+
stats.min = min(stats.min, bpm)
|
|
1858
|
+
stats.max = max(stats.max, bpm)
|
|
1859
|
+
heartRateMap[dateString] = stats
|
|
1860
|
+
lock.unlock()
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
healthStore.execute(query)
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// === RESTING HEART RATE ===
|
|
1867
|
+
if let restingHRType = HKObjectType.quantityType(forIdentifier: .restingHeartRate) {
|
|
1868
|
+
group.enter()
|
|
1869
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1870
|
+
let query = HKSampleQuery(sampleType: restingHRType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1871
|
+
defer { group.leave() }
|
|
1872
|
+
|
|
1873
|
+
if let error = error {
|
|
1874
|
+
lock.lock()
|
|
1875
|
+
errors.append(error)
|
|
1876
|
+
lock.unlock()
|
|
1877
|
+
return
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1881
|
+
|
|
1882
|
+
for sample in quantitySamples {
|
|
1883
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1884
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1885
|
+
|
|
1886
|
+
let bpm = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
|
|
1887
|
+
lock.lock()
|
|
1888
|
+
restingHRMap[dateString] = bpm // Take last measurement
|
|
1889
|
+
lock.unlock()
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
healthStore.execute(query)
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// === VO2 MAX ===
|
|
1896
|
+
if let vo2MaxType = HKObjectType.quantityType(forIdentifier: .vo2Max) {
|
|
1897
|
+
group.enter()
|
|
1898
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1899
|
+
let query = HKSampleQuery(sampleType: vo2MaxType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1900
|
+
defer { group.leave() }
|
|
1901
|
+
|
|
1902
|
+
if let error = error {
|
|
1903
|
+
lock.lock()
|
|
1904
|
+
errors.append(error)
|
|
1905
|
+
lock.unlock()
|
|
1906
|
+
return
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1910
|
+
|
|
1911
|
+
for sample in quantitySamples {
|
|
1912
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1913
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1914
|
+
|
|
1915
|
+
let vo2 = sample.quantity.doubleValue(for: HKUnit.literUnit(with: .milli).unitDivided(by: HKUnit.gramUnit(with: .kilo).unitMultiplied(by: HKUnit.minute())))
|
|
1916
|
+
lock.lock()
|
|
1917
|
+
vo2MaxMap[dateString] = vo2 // Take last measurement
|
|
1918
|
+
lock.unlock()
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
healthStore.execute(query)
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// === HRV (Heart Rate Variability SDNN) ===
|
|
1925
|
+
if let hrvType = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN) {
|
|
1926
|
+
group.enter()
|
|
1927
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1928
|
+
let query = HKSampleQuery(sampleType: hrvType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1929
|
+
defer { group.leave() }
|
|
1930
|
+
|
|
1931
|
+
if let error = error {
|
|
1932
|
+
lock.lock()
|
|
1933
|
+
errors.append(error)
|
|
1934
|
+
lock.unlock()
|
|
1935
|
+
return
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1939
|
+
|
|
1940
|
+
for sample in quantitySamples {
|
|
1941
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1942
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1943
|
+
|
|
1944
|
+
let hrv = sample.quantity.doubleValue(for: HKUnit.secondUnit(with: .milli))
|
|
1945
|
+
lock.lock()
|
|
1946
|
+
hrvMap[dateString] = hrv // Take last measurement
|
|
1947
|
+
lock.unlock()
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
healthStore.execute(query)
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// === SPO2 (Blood Oxygen Saturation) ===
|
|
1954
|
+
if let spo2Type = HKObjectType.quantityType(forIdentifier: .oxygenSaturation) {
|
|
1955
|
+
group.enter()
|
|
1956
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1957
|
+
let query = HKSampleQuery(sampleType: spo2Type, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1958
|
+
defer { group.leave() }
|
|
1959
|
+
|
|
1960
|
+
if let error = error {
|
|
1961
|
+
lock.lock()
|
|
1962
|
+
errors.append(error)
|
|
1963
|
+
lock.unlock()
|
|
1964
|
+
return
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1968
|
+
|
|
1969
|
+
for sample in quantitySamples {
|
|
1970
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1971
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1972
|
+
|
|
1973
|
+
// HealthKit stores as decimal (0.96), convert to percentage (96)
|
|
1974
|
+
let value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
|
|
1975
|
+
lock.lock()
|
|
1976
|
+
if spo2Map[dateString] == nil {
|
|
1977
|
+
spo2Map[dateString] = []
|
|
1978
|
+
}
|
|
1979
|
+
spo2Map[dateString]?.append(value)
|
|
1980
|
+
lock.unlock()
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
healthStore.execute(query)
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// === RESPIRATION RATE ===
|
|
1987
|
+
if let respirationRateType = HKObjectType.quantityType(forIdentifier: .respiratoryRate) {
|
|
1988
|
+
group.enter()
|
|
1989
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1990
|
+
let query = HKSampleQuery(sampleType: respirationRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1991
|
+
defer { group.leave() }
|
|
1992
|
+
|
|
1993
|
+
if let error = error {
|
|
1994
|
+
lock.lock()
|
|
1995
|
+
errors.append(error)
|
|
1996
|
+
lock.unlock()
|
|
1997
|
+
return
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
2001
|
+
|
|
2002
|
+
for sample in quantitySamples {
|
|
2003
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
2004
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
2005
|
+
|
|
2006
|
+
let breathsPerMin = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
|
|
2007
|
+
lock.lock()
|
|
2008
|
+
if respirationRateMap[dateString] == nil {
|
|
2009
|
+
respirationRateMap[dateString] = []
|
|
2010
|
+
}
|
|
2011
|
+
respirationRateMap[dateString]?.append(breathsPerMin)
|
|
2012
|
+
lock.unlock()
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
healthStore.execute(query)
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
group.notify(queue: .main) {
|
|
2019
|
+
// Only fail if there are errors AND no data was collected
|
|
2020
|
+
// This allows partial data to be returned if some permissions are denied
|
|
2021
|
+
if !errors.isEmpty {
|
|
2022
|
+
// Check if any data was actually collected
|
|
2023
|
+
let hasData = !heartRateMap.isEmpty || !restingHRMap.isEmpty || !vo2MaxMap.isEmpty ||
|
|
2024
|
+
!hrvMap.isEmpty || !spo2Map.isEmpty || !respirationRateMap.isEmpty
|
|
2025
|
+
|
|
2026
|
+
if !hasData {
|
|
2027
|
+
// No data at all, return the error
|
|
2028
|
+
completion(.failure(errors.first!))
|
|
2029
|
+
return
|
|
2030
|
+
}
|
|
2031
|
+
// If we have some data, continue and return what we have
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// Collect all unique dates that have any heart data
|
|
2035
|
+
var allDates = Set<String>()
|
|
2036
|
+
allDates.formUnion(heartRateMap.keys)
|
|
2037
|
+
allDates.formUnion(restingHRMap.keys)
|
|
2038
|
+
allDates.formUnion(vo2MaxMap.keys)
|
|
2039
|
+
allDates.formUnion(hrvMap.keys)
|
|
2040
|
+
allDates.formUnion(spo2Map.keys)
|
|
2041
|
+
allDates.formUnion(respirationRateMap.keys)
|
|
2042
|
+
|
|
2043
|
+
// Create heart data array matching the TypeScript format
|
|
2044
|
+
let heartData: [[String: Any]] = allDates.sorted().compactMap { date in
|
|
2045
|
+
var result: [String: Any] = ["date": date]
|
|
2046
|
+
|
|
2047
|
+
// Heart Rate (avg, min, max, count)
|
|
2048
|
+
if let stats = heartRateMap[date] {
|
|
2049
|
+
result["avgBpm"] = Int(round(stats.sum / Double(stats.count)))
|
|
2050
|
+
result["minBpm"] = Int(round(stats.min))
|
|
2051
|
+
result["maxBpm"] = Int(round(stats.max))
|
|
2052
|
+
result["heartRateMeasurements"] = stats.count
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// Resting Heart Rate
|
|
2056
|
+
if let restingBpm = restingHRMap[date] {
|
|
2057
|
+
result["restingBpm"] = Int(round(restingBpm))
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// VO2 Max (rounded to 1 decimal)
|
|
2061
|
+
if let vo2 = vo2MaxMap[date] {
|
|
2062
|
+
result["vo2Max"] = round(vo2 * 10) / 10
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// HRV (rounded to 3 decimals)
|
|
2066
|
+
if let hrv = hrvMap[date] {
|
|
2067
|
+
result["hrv"] = round(hrv * 1000) / 1000
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
// SpO2 (average)
|
|
2071
|
+
if let readings = spo2Map[date], !readings.isEmpty {
|
|
2072
|
+
let average = readings.reduce(0.0, +) / Double(readings.count)
|
|
2073
|
+
result["spo2"] = Int(round(average))
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// Respiration Rate (average, rounded to 1 decimal)
|
|
2077
|
+
if let readings = respirationRateMap[date], !readings.isEmpty {
|
|
2078
|
+
let average = readings.reduce(0.0, +) / Double(readings.count)
|
|
2079
|
+
result["respirationRate"] = round(average * 10) / 10
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
return result
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
completion(.success(heartData))
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// MARK: - Workout Data Processing
|
|
2090
|
+
|
|
2091
|
+
private func processWorkoutData(startDate: Date, endDate: Date, limit: Int?, ascending: Bool, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
|
|
2092
|
+
let workoutType = HKObjectType.workoutType()
|
|
2093
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
2094
|
+
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: ascending)
|
|
2095
|
+
let queryLimit = limit ?? HKObjectQueryNoLimit
|
|
2096
|
+
|
|
2097
|
+
let query = HKSampleQuery(sampleType: workoutType, predicate: predicate, limit: queryLimit, sortDescriptors: [sortDescriptor]) { [weak self] _, samples, error in
|
|
2098
|
+
guard let self = self else { return }
|
|
2099
|
+
|
|
2100
|
+
if let error = error {
|
|
2101
|
+
completion(.failure(error))
|
|
2102
|
+
return
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
guard let workouts = samples as? [HKWorkout], !workouts.isEmpty else {
|
|
2106
|
+
completion(.success([]))
|
|
2107
|
+
return
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
var workoutData: [[String: Any]] = []
|
|
2111
|
+
let group = DispatchGroup()
|
|
2112
|
+
let lock = NSLock()
|
|
2113
|
+
|
|
2114
|
+
for workout in workouts {
|
|
2115
|
+
group.enter()
|
|
2116
|
+
|
|
2117
|
+
// Extract basic workout info
|
|
2118
|
+
let dateFormatter = ISO8601DateFormatter()
|
|
2119
|
+
dateFormatter.formatOptions = [.withFullDate]
|
|
2120
|
+
let date = dateFormatter.string(from: workout.startDate)
|
|
2121
|
+
|
|
2122
|
+
// Get workout type (remove "HKWorkoutActivityType" prefix to match XML format)
|
|
2123
|
+
let activityTypeString = self.workoutActivityTypeString(for: workout.workoutActivityType)
|
|
2124
|
+
|
|
2125
|
+
// Duration in minutes (matching XML parser logic)
|
|
2126
|
+
let durationInMinutes = Int(round(workout.duration / 60.0))
|
|
2127
|
+
|
|
2128
|
+
// Distance in miles (if available)
|
|
2129
|
+
var distance: Double?
|
|
2130
|
+
if let totalDistance = workout.totalDistance {
|
|
2131
|
+
let distanceInMeters = totalDistance.doubleValue(for: HKUnit.meter())
|
|
2132
|
+
distance = distanceInMeters * 0.000621371 // meters to miles
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// Calories (if available)
|
|
2136
|
+
var calories: Int?
|
|
2137
|
+
if let totalEnergy = workout.totalEnergyBurned {
|
|
2138
|
+
let caloriesValue = totalEnergy.doubleValue(for: HKUnit.kilocalorie())
|
|
2139
|
+
calories = Int(round(caloriesValue))
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// Source name
|
|
2143
|
+
let source = workout.sourceRevision.source.name
|
|
2144
|
+
|
|
2145
|
+
// Query for heart rate statistics during this workout
|
|
2146
|
+
self.queryWorkoutHeartRateStatistics(for: workout) { avgHR, maxHR in
|
|
2147
|
+
// Query for heart rate zones
|
|
2148
|
+
self.queryWorkoutHeartRateZones(for: workout) { zones in
|
|
2149
|
+
lock.lock()
|
|
2150
|
+
|
|
2151
|
+
var workoutDict: [String: Any] = [
|
|
2152
|
+
"date": date,
|
|
2153
|
+
"type": activityTypeString,
|
|
2154
|
+
"duration": durationInMinutes,
|
|
2155
|
+
"source": source
|
|
2156
|
+
]
|
|
2157
|
+
|
|
2158
|
+
if let distance = distance {
|
|
2159
|
+
workoutDict["distance"] = round(distance * 100) / 100 // 2 decimal places
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
if let calories = calories {
|
|
2163
|
+
workoutDict["calories"] = calories
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
if let avgHR = avgHR {
|
|
2167
|
+
workoutDict["avgHeartRate"] = avgHR
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
if let maxHR = maxHR {
|
|
2171
|
+
workoutDict["maxHeartRate"] = maxHR
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
if !zones.isEmpty {
|
|
2175
|
+
workoutDict["zones"] = zones
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
workoutData.append(workoutDict)
|
|
2179
|
+
lock.unlock()
|
|
2180
|
+
|
|
2181
|
+
group.leave()
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
group.notify(queue: .main) {
|
|
2187
|
+
// Sort by date if needed
|
|
2188
|
+
let sortedData = workoutData.sorted { dict1, dict2 in
|
|
2189
|
+
guard let date1 = dict1["date"] as? String,
|
|
2190
|
+
let date2 = dict2["date"] as? String else {
|
|
2191
|
+
return false
|
|
2192
|
+
}
|
|
2193
|
+
return ascending ? date1 < date2 : date1 > date2
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
completion(.success(sortedData))
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
healthStore.execute(query)
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
private func workoutActivityTypeString(for type: HKWorkoutActivityType) -> String {
|
|
2204
|
+
// Return the activity type name without the "HKWorkoutActivityType" prefix
|
|
2205
|
+
// to match the XML export format
|
|
2206
|
+
switch type {
|
|
2207
|
+
case .running: return "Running"
|
|
2208
|
+
case .cycling: return "Cycling"
|
|
2209
|
+
case .walking: return "Walking"
|
|
2210
|
+
case .swimming: return "Swimming"
|
|
2211
|
+
case .yoga: return "Yoga"
|
|
2212
|
+
case .functionalStrengthTraining: return "FunctionalStrengthTraining"
|
|
2213
|
+
case .traditionalStrengthTraining: return "TraditionalStrengthTraining"
|
|
2214
|
+
case .elliptical: return "Elliptical"
|
|
2215
|
+
case .rowing: return "Rowing"
|
|
2216
|
+
case .hiking: return "Hiking"
|
|
2217
|
+
case .highIntensityIntervalTraining: return "HighIntensityIntervalTraining"
|
|
2218
|
+
case .dance: return "Dance"
|
|
2219
|
+
case .basketball: return "Basketball"
|
|
2220
|
+
case .soccer: return "Soccer"
|
|
2221
|
+
case .tennis: return "Tennis"
|
|
2222
|
+
case .golf: return "Golf"
|
|
2223
|
+
case .stairClimbing: return "StairClimbing"
|
|
2224
|
+
case .stepTraining: return "StepTraining"
|
|
2225
|
+
case .kickboxing: return "Kickboxing"
|
|
2226
|
+
case .pilates: return "Pilates"
|
|
2227
|
+
case .boxing: return "Boxing"
|
|
2228
|
+
case .taiChi: return "TaiChi"
|
|
2229
|
+
case .crossTraining: return "CrossTraining"
|
|
2230
|
+
case .mindAndBody: return "MindAndBody"
|
|
2231
|
+
case .coreTraining: return "CoreTraining"
|
|
2232
|
+
case .flexibility: return "Flexibility"
|
|
2233
|
+
case .cooldown: return "Cooldown"
|
|
2234
|
+
case .wheelchairWalkPace: return "WheelchairWalkPace"
|
|
2235
|
+
case .wheelchairRunPace: return "WheelchairRunPace"
|
|
2236
|
+
case .other: return "Other"
|
|
2237
|
+
default:
|
|
2238
|
+
// For any unknown or new types
|
|
2239
|
+
return "Other"
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
private func queryWorkoutHeartRateStatistics(for workout: HKWorkout, completion: @escaping (Int?, Int?) -> Void) {
|
|
2244
|
+
guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) else {
|
|
2245
|
+
completion(nil, nil)
|
|
2246
|
+
return
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
let predicate = HKQuery.predicateForSamples(withStart: workout.startDate, end: workout.endDate, options: .strictStartDate)
|
|
2250
|
+
|
|
2251
|
+
let query = HKStatisticsQuery(quantityType: heartRateType, quantitySamplePredicate: predicate, options: [.discreteAverage, .discreteMax]) { _, statistics, error in
|
|
2252
|
+
if error != nil {
|
|
2253
|
+
completion(nil, nil)
|
|
2254
|
+
return
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
var avgHR: Int?
|
|
2258
|
+
var maxHR: Int?
|
|
2259
|
+
|
|
2260
|
+
if let avgQuantity = statistics?.averageQuantity() {
|
|
2261
|
+
let bpm = avgQuantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
|
|
2262
|
+
avgHR = Int(round(bpm))
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
if let maxQuantity = statistics?.maximumQuantity() {
|
|
2266
|
+
let bpm = maxQuantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
|
|
2267
|
+
maxHR = Int(round(bpm))
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
completion(avgHR, maxHR)
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
healthStore.execute(query)
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
private func queryWorkoutHeartRateZones(for workout: HKWorkout, completion: @escaping ([String: Int]) -> Void) {
|
|
2277
|
+
// Check if heart rate zone data is available in workout metadata
|
|
2278
|
+
var zones: [String: Int] = [:]
|
|
2279
|
+
|
|
2280
|
+
if let metadata = workout.metadata {
|
|
2281
|
+
// Look for heart rate zone keys in metadata
|
|
2282
|
+
// Apple Health stores zones as HKMetadataKeyHeartRateEventThreshold or custom keys
|
|
2283
|
+
for (key, value) in metadata {
|
|
2284
|
+
if key.contains("Zone") || key.contains("zone") {
|
|
2285
|
+
// Try to extract zone number
|
|
2286
|
+
if let zoneMatch = key.range(of: #"\d+"#, options: .regularExpression),
|
|
2287
|
+
let zoneNum = Int(key[zoneMatch]) {
|
|
2288
|
+
// Value might be in seconds, convert to minutes
|
|
2289
|
+
if let seconds = value as? Double {
|
|
2290
|
+
let minutes = Int(round(seconds / 60.0))
|
|
2291
|
+
zones["zone\(zoneNum)"] = minutes
|
|
2292
|
+
} else if let minutes = value as? Int {
|
|
2293
|
+
zones["zone\(zoneNum)"] = minutes
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// If no zones found in metadata, try querying heart rate samples to calculate zones
|
|
2301
|
+
if zones.isEmpty {
|
|
2302
|
+
calculateHeartRateZones(for: workout) { calculatedZones in
|
|
2303
|
+
completion(calculatedZones)
|
|
2304
|
+
}
|
|
2305
|
+
} else {
|
|
2306
|
+
completion(zones)
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
private func calculateHeartRateZones(for workout: HKWorkout, completion: @escaping ([String: Int]) -> Void) {
|
|
2311
|
+
guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) else {
|
|
2312
|
+
completion([:])
|
|
2313
|
+
return
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
let predicate = HKQuery.predicateForSamples(withStart: workout.startDate, end: workout.endDate, options: .strictStartDate)
|
|
2317
|
+
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
|
2318
|
+
|
|
2319
|
+
let query = HKSampleQuery(sampleType: heartRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sortDescriptor]) { _, samples, error in
|
|
2320
|
+
if error != nil || samples == nil {
|
|
2321
|
+
completion([:])
|
|
2322
|
+
return
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
guard let heartRateSamples = samples as? [HKQuantitySample], !heartRateSamples.isEmpty else {
|
|
2326
|
+
completion([:])
|
|
2327
|
+
return
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
// Calculate zones based on standard heart rate zone definitions
|
|
2331
|
+
// Zone 1: 50-60% max HR
|
|
2332
|
+
// Zone 2: 60-70% max HR
|
|
2333
|
+
// Zone 3: 70-80% max HR
|
|
2334
|
+
// Zone 4: 80-90% max HR
|
|
2335
|
+
// Zone 5: 90-100% max HR
|
|
2336
|
+
|
|
2337
|
+
// Estimate max HR (220 - age), or use 180 as a reasonable default
|
|
2338
|
+
let estimatedMaxHR = 180.0
|
|
2339
|
+
|
|
2340
|
+
var zoneMinutes: [Int: TimeInterval] = [1: 0, 2: 0, 3: 0, 4: 0, 5: 0]
|
|
2341
|
+
|
|
2342
|
+
for i in 0..<heartRateSamples.count {
|
|
2343
|
+
let sample = heartRateSamples[i]
|
|
2344
|
+
let bpm = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
|
|
2345
|
+
let percentMax = (bpm / estimatedMaxHR) * 100
|
|
2346
|
+
|
|
2347
|
+
// Determine zone
|
|
2348
|
+
let zone: Int
|
|
2349
|
+
if percentMax < 60 {
|
|
2350
|
+
zone = 1
|
|
2351
|
+
} else if percentMax < 70 {
|
|
2352
|
+
zone = 2
|
|
2353
|
+
} else if percentMax < 80 {
|
|
2354
|
+
zone = 3
|
|
2355
|
+
} else if percentMax < 90 {
|
|
2356
|
+
zone = 4
|
|
2357
|
+
} else {
|
|
2358
|
+
zone = 5
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// Calculate time in this zone (use interval to next sample or default to 5 seconds)
|
|
2362
|
+
let duration: TimeInterval
|
|
2363
|
+
if i < heartRateSamples.count - 1 {
|
|
2364
|
+
duration = heartRateSamples[i + 1].startDate.timeIntervalSince(sample.startDate)
|
|
2365
|
+
} else {
|
|
2366
|
+
duration = 5.0 // Default 5 seconds for last sample
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
zoneMinutes[zone, default: 0] += duration
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// Convert seconds to minutes and filter out zones with 0 minutes
|
|
2373
|
+
var zones: [String: Int] = [:]
|
|
2374
|
+
for (zone, seconds) in zoneMinutes {
|
|
2375
|
+
let minutes = Int(round(seconds / 60.0))
|
|
2376
|
+
if minutes > 0 {
|
|
2377
|
+
zones["zone\(zone)"] = minutes
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
completion(zones)
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
healthStore.execute(query)
|
|
2385
|
+
}
|
|
522
2386
|
}
|