@interval-health/capacitor-health 1.0.1 → 1.0.3
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/IntervalHealthCapacitorHealth.podspec +18 -0
- package/README.md +730 -193
- package/android/src/main/java/app/capgo/plugin/health/HealthDataType.kt +2 -1
- package/dist/docs.json +20 -0
- package/dist/esm/definitions.d.ts +2 -2
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/HealthPlugin/Health.swift +1598 -57
- package/package.json +2 -2
|
@@ -34,6 +34,10 @@ 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
|
|
37
41
|
|
|
38
42
|
func sampleType() throws -> HKSampleType {
|
|
39
43
|
if self == .sleep {
|
|
@@ -43,6 +47,38 @@ enum HealthDataType: String, CaseIterable {
|
|
|
43
47
|
return type
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
if self == .mobility {
|
|
51
|
+
// Mobility uses multiple types, return walkingSpeed as representative
|
|
52
|
+
guard let type = HKObjectType.quantityType(forIdentifier: .walkingSpeed) else {
|
|
53
|
+
throw HealthManagerError.dataTypeUnavailable(rawValue)
|
|
54
|
+
}
|
|
55
|
+
return type
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if self == .activity {
|
|
59
|
+
// Activity uses multiple types, return stepCount as representative
|
|
60
|
+
guard let type = HKObjectType.quantityType(forIdentifier: .stepCount) else {
|
|
61
|
+
throw HealthManagerError.dataTypeUnavailable(rawValue)
|
|
62
|
+
}
|
|
63
|
+
return type
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if self == .heart {
|
|
67
|
+
// Heart uses multiple types, return heartRate as representative
|
|
68
|
+
guard let type = HKObjectType.quantityType(forIdentifier: .heartRate) else {
|
|
69
|
+
throw HealthManagerError.dataTypeUnavailable(rawValue)
|
|
70
|
+
}
|
|
71
|
+
return type
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if self == .body {
|
|
75
|
+
// Body uses multiple types, return bodyMass as representative
|
|
76
|
+
guard let type = HKObjectType.quantityType(forIdentifier: .bodyMass) else {
|
|
77
|
+
throw HealthManagerError.dataTypeUnavailable(rawValue)
|
|
78
|
+
}
|
|
79
|
+
return type
|
|
80
|
+
}
|
|
81
|
+
|
|
46
82
|
let identifier: HKQuantityTypeIdentifier
|
|
47
83
|
switch self {
|
|
48
84
|
case .steps:
|
|
@@ -57,6 +93,14 @@ enum HealthDataType: String, CaseIterable {
|
|
|
57
93
|
identifier = .bodyMass
|
|
58
94
|
case .sleep:
|
|
59
95
|
fatalError("Sleep should have been handled above")
|
|
96
|
+
case .mobility:
|
|
97
|
+
fatalError("Mobility should have been handled above")
|
|
98
|
+
case .activity:
|
|
99
|
+
fatalError("Activity should have been handled above")
|
|
100
|
+
case .heart:
|
|
101
|
+
fatalError("Heart should have been handled above")
|
|
102
|
+
case .body:
|
|
103
|
+
fatalError("Body should have been handled above")
|
|
60
104
|
}
|
|
61
105
|
|
|
62
106
|
guard let type = HKObjectType.quantityType(forIdentifier: identifier) else {
|
|
@@ -79,6 +123,14 @@ enum HealthDataType: String, CaseIterable {
|
|
|
79
123
|
return HKUnit.gramUnit(with: .kilo)
|
|
80
124
|
case .sleep:
|
|
81
125
|
return HKUnit.minute() // Sleep duration in minutes
|
|
126
|
+
case .mobility:
|
|
127
|
+
return HKUnit.meter() // Placeholder, mobility has multiple units
|
|
128
|
+
case .activity:
|
|
129
|
+
return HKUnit.count() // Placeholder, activity has multiple units
|
|
130
|
+
case .heart:
|
|
131
|
+
return HKUnit.count().unitDivided(by: HKUnit.minute()) // Placeholder, heart has multiple units
|
|
132
|
+
case .body:
|
|
133
|
+
return HKUnit.gramUnit(with: .kilo) // Placeholder, body has multiple units
|
|
82
134
|
}
|
|
83
135
|
}
|
|
84
136
|
|
|
@@ -96,6 +148,14 @@ enum HealthDataType: String, CaseIterable {
|
|
|
96
148
|
return "kilogram"
|
|
97
149
|
case .sleep:
|
|
98
150
|
return "minute"
|
|
151
|
+
case .mobility:
|
|
152
|
+
return "mixed" // Mobility has multiple units
|
|
153
|
+
case .activity:
|
|
154
|
+
return "mixed" // Activity has multiple units
|
|
155
|
+
case .heart:
|
|
156
|
+
return "mixed" // Heart has multiple units
|
|
157
|
+
case .body:
|
|
158
|
+
return "mixed" // Body has multiple units
|
|
99
159
|
}
|
|
100
160
|
}
|
|
101
161
|
|
|
@@ -200,8 +260,7 @@ final class Health {
|
|
|
200
260
|
|
|
201
261
|
func readSamples(dataTypeIdentifier: String, startDateString: String?, endDateString: String?, limit: Int?, ascending: Bool, completion: @escaping (Result<[[String: Any]], Error>) -> Void) throws {
|
|
202
262
|
let dataType = try parseDataType(identifier: dataTypeIdentifier)
|
|
203
|
-
|
|
204
|
-
|
|
263
|
+
|
|
205
264
|
let startDate = try parseDate(startDateString, defaultValue: Date().addingTimeInterval(-86400))
|
|
206
265
|
let endDate = try parseDate(endDateString, defaultValue: Date())
|
|
207
266
|
|
|
@@ -209,6 +268,64 @@ final class Health {
|
|
|
209
268
|
throw HealthManagerError.invalidDateRange
|
|
210
269
|
}
|
|
211
270
|
|
|
271
|
+
// Handle body data (multiple quantity types)
|
|
272
|
+
// Skip the initial query and go directly to processing to avoid authorization issues
|
|
273
|
+
if dataType == .body {
|
|
274
|
+
processBodyData(startDate: startDate, endDate: endDate) { result in
|
|
275
|
+
switch result {
|
|
276
|
+
case .success(let bodyData):
|
|
277
|
+
completion(.success(bodyData))
|
|
278
|
+
case .failure(let error):
|
|
279
|
+
completion(.failure(error))
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Handle heart data (multiple quantity types)
|
|
286
|
+
// Skip the initial query and go directly to processing to avoid authorization issues
|
|
287
|
+
if dataType == .heart {
|
|
288
|
+
processHeartData(startDate: startDate, endDate: endDate) { result in
|
|
289
|
+
switch result {
|
|
290
|
+
case .success(let heartData):
|
|
291
|
+
completion(.success(heartData))
|
|
292
|
+
case .failure(let error):
|
|
293
|
+
completion(.failure(error))
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Handle activity data (multiple quantity and category types)
|
|
300
|
+
// Skip the initial query and go directly to processing to avoid authorization issues
|
|
301
|
+
if dataType == .activity {
|
|
302
|
+
processActivityData(startDate: startDate, endDate: endDate) { result in
|
|
303
|
+
switch result {
|
|
304
|
+
case .success(let activityData):
|
|
305
|
+
completion(.success(activityData))
|
|
306
|
+
case .failure(let error):
|
|
307
|
+
completion(.failure(error))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Handle mobility data (multiple quantity types)
|
|
314
|
+
// Skip the initial query and go directly to processing to avoid authorization issues
|
|
315
|
+
if dataType == .mobility {
|
|
316
|
+
processMobilityData(startDate: startDate, endDate: endDate) { result in
|
|
317
|
+
switch result {
|
|
318
|
+
case .success(let mobilityData):
|
|
319
|
+
completion(.success(mobilityData))
|
|
320
|
+
case .failure(let error):
|
|
321
|
+
completion(.failure(error))
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// For all other data types, use the standard query approach
|
|
328
|
+
let sampleType = try dataType.sampleType()
|
|
212
329
|
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
213
330
|
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: ascending)
|
|
214
331
|
let queryLimit = limit ?? 100
|
|
@@ -225,7 +342,7 @@ final class Health {
|
|
|
225
342
|
completion(.success([]))
|
|
226
343
|
return
|
|
227
344
|
}
|
|
228
|
-
|
|
345
|
+
|
|
229
346
|
// Handle sleep data (category samples)
|
|
230
347
|
if dataType == .sleep {
|
|
231
348
|
guard let categorySamples = samples as? [HKCategorySample] else {
|
|
@@ -233,27 +350,9 @@ final class Health {
|
|
|
233
350
|
return
|
|
234
351
|
}
|
|
235
352
|
|
|
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))
|
|
353
|
+
// Process sleep data similar to client-side parser
|
|
354
|
+
let processedSleepData = self.processSleepSamples(categorySamples)
|
|
355
|
+
completion(.success(processedSleepData))
|
|
257
356
|
return
|
|
258
357
|
}
|
|
259
358
|
|
|
@@ -363,18 +462,110 @@ final class Health {
|
|
|
363
462
|
var denied: [HealthDataType] = []
|
|
364
463
|
|
|
365
464
|
for type in types {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
465
|
+
// Special handling for activity - check all activity types
|
|
466
|
+
// Consider authorized if at least one activity type is authorized
|
|
467
|
+
if type == .activity {
|
|
468
|
+
let activityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
469
|
+
.stepCount,
|
|
470
|
+
.distanceWalkingRunning,
|
|
471
|
+
.flightsClimbed,
|
|
472
|
+
.activeEnergyBurned,
|
|
473
|
+
.appleExerciseTime
|
|
474
|
+
]
|
|
475
|
+
|
|
476
|
+
var hasAnyAuthorized = false
|
|
477
|
+
for identifier in activityIdentifiers {
|
|
478
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
479
|
+
let status = healthStore.authorizationStatus(for: quantityType)
|
|
480
|
+
if status == .sharingAuthorized {
|
|
481
|
+
hasAnyAuthorized = true
|
|
482
|
+
break
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Also check stand hour (category type)
|
|
488
|
+
if !hasAnyAuthorized {
|
|
489
|
+
if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
|
|
490
|
+
let status = healthStore.authorizationStatus(for: standHourType)
|
|
491
|
+
if status == .sharingAuthorized {
|
|
492
|
+
hasAnyAuthorized = true
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if hasAnyAuthorized {
|
|
498
|
+
authorized.append(type)
|
|
499
|
+
} else {
|
|
500
|
+
denied.append(type)
|
|
501
|
+
}
|
|
369
502
|
}
|
|
503
|
+
// Special handling for body - check body composition types
|
|
504
|
+
// Consider authorized if at least one body type is authorized
|
|
505
|
+
else if type == .body {
|
|
506
|
+
let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
507
|
+
.bodyMass,
|
|
508
|
+
.bodyFatPercentage
|
|
509
|
+
]
|
|
510
|
+
|
|
511
|
+
var hasAnyAuthorized = false
|
|
512
|
+
for identifier in bodyIdentifiers {
|
|
513
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
514
|
+
let status = healthStore.authorizationStatus(for: quantityType)
|
|
515
|
+
if status == .sharingAuthorized {
|
|
516
|
+
hasAnyAuthorized = true
|
|
517
|
+
break
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if hasAnyAuthorized {
|
|
523
|
+
authorized.append(type)
|
|
524
|
+
} else {
|
|
525
|
+
denied.append(type)
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// Special handling for mobility - check all 6 types
|
|
529
|
+
else if type == .mobility {
|
|
530
|
+
let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
531
|
+
.walkingSpeed,
|
|
532
|
+
.walkingStepLength,
|
|
533
|
+
.walkingAsymmetryPercentage,
|
|
534
|
+
.walkingDoubleSupportPercentage,
|
|
535
|
+
.stairAscentSpeed,
|
|
536
|
+
.sixMinuteWalkTestDistance
|
|
537
|
+
]
|
|
538
|
+
|
|
539
|
+
var allAuthorized = true
|
|
540
|
+
for identifier in mobilityIdentifiers {
|
|
541
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
542
|
+
let status = healthStore.authorizationStatus(for: quantityType)
|
|
543
|
+
if status != .sharingAuthorized {
|
|
544
|
+
allAuthorized = false
|
|
545
|
+
break
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if allAuthorized {
|
|
551
|
+
authorized.append(type)
|
|
552
|
+
} else {
|
|
553
|
+
denied.append(type)
|
|
554
|
+
}
|
|
555
|
+
} else {
|
|
556
|
+
guard let sampleType = try? type.sampleType() else {
|
|
557
|
+
denied.append(type)
|
|
558
|
+
continue
|
|
559
|
+
}
|
|
370
560
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
561
|
+
switch healthStore.authorizationStatus(for: sampleType) {
|
|
562
|
+
case .sharingAuthorized:
|
|
563
|
+
authorized.append(type)
|
|
564
|
+
case .sharingDenied, .notDetermined:
|
|
565
|
+
denied.append(type)
|
|
566
|
+
@unknown default:
|
|
567
|
+
denied.append(type)
|
|
568
|
+
}
|
|
378
569
|
}
|
|
379
570
|
}
|
|
380
571
|
|
|
@@ -394,28 +585,174 @@ final class Health {
|
|
|
394
585
|
var denied: [HealthDataType] = []
|
|
395
586
|
|
|
396
587
|
for type in types {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
588
|
+
// Special handling for activity - check all activity types
|
|
589
|
+
if type == .activity {
|
|
590
|
+
let activityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
591
|
+
.stepCount,
|
|
592
|
+
.distanceWalkingRunning,
|
|
593
|
+
.flightsClimbed,
|
|
594
|
+
.activeEnergyBurned,
|
|
595
|
+
.appleExerciseTime
|
|
596
|
+
]
|
|
597
|
+
|
|
598
|
+
// Check all 5 quantity types + 1 category type
|
|
599
|
+
var activityAuthorizedCount = 0
|
|
600
|
+
let totalActivityTypes = activityIdentifiers.count + 1 // +1 for stand hour
|
|
601
|
+
|
|
602
|
+
// Create a nested group for activity checks
|
|
603
|
+
let activityGroup = DispatchGroup()
|
|
604
|
+
|
|
605
|
+
for identifier in activityIdentifiers {
|
|
606
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
607
|
+
activityGroup.enter()
|
|
608
|
+
let readSet = Set<HKObjectType>([quantityType])
|
|
609
|
+
healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
|
|
610
|
+
defer { activityGroup.leave() }
|
|
611
|
+
|
|
612
|
+
if error == nil && status == .unnecessary {
|
|
613
|
+
lock.lock()
|
|
614
|
+
activityAuthorizedCount += 1
|
|
615
|
+
lock.unlock()
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Check stand hour (category type)
|
|
622
|
+
if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
|
|
623
|
+
activityGroup.enter()
|
|
624
|
+
let readSet = Set<HKObjectType>([standHourType])
|
|
625
|
+
healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
|
|
626
|
+
defer { activityGroup.leave() }
|
|
627
|
+
|
|
628
|
+
if error == nil && status == .unnecessary {
|
|
629
|
+
lock.lock()
|
|
630
|
+
activityAuthorizedCount += 1
|
|
631
|
+
lock.unlock()
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Wait for all activity checks, then determine if activity is authorized
|
|
637
|
+
// Consider authorized if at least one activity type is authorized
|
|
638
|
+
activityGroup.notify(queue: .main) {
|
|
639
|
+
lock.lock()
|
|
640
|
+
if activityAuthorizedCount > 0 {
|
|
641
|
+
authorized.append(type)
|
|
642
|
+
} else {
|
|
643
|
+
denied.append(type)
|
|
644
|
+
}
|
|
645
|
+
lock.unlock()
|
|
646
|
+
}
|
|
400
647
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
648
|
+
// Special handling for body - check body composition types
|
|
649
|
+
else if type == .body {
|
|
650
|
+
let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
651
|
+
.bodyMass,
|
|
652
|
+
.bodyFatPercentage
|
|
653
|
+
]
|
|
654
|
+
|
|
655
|
+
// Check all body types
|
|
656
|
+
var bodyAuthorizedCount = 0
|
|
657
|
+
|
|
658
|
+
// Create a nested group for body checks
|
|
659
|
+
let bodyGroup = DispatchGroup()
|
|
660
|
+
|
|
661
|
+
for identifier in bodyIdentifiers {
|
|
662
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
663
|
+
bodyGroup.enter()
|
|
664
|
+
let readSet = Set<HKObjectType>([quantityType])
|
|
665
|
+
healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
|
|
666
|
+
defer { bodyGroup.leave() }
|
|
667
|
+
|
|
668
|
+
if error == nil && status == .unnecessary {
|
|
669
|
+
lock.lock()
|
|
670
|
+
bodyAuthorizedCount += 1
|
|
671
|
+
lock.unlock()
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Wait for all body checks, then determine if body is authorized
|
|
678
|
+
// Consider authorized if at least one body type is authorized
|
|
679
|
+
bodyGroup.notify(queue: .main) {
|
|
680
|
+
lock.lock()
|
|
681
|
+
if bodyAuthorizedCount > 0 {
|
|
682
|
+
authorized.append(type)
|
|
683
|
+
} else {
|
|
684
|
+
denied.append(type)
|
|
685
|
+
}
|
|
686
|
+
lock.unlock()
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// Special handling for mobility - check all 6 types
|
|
690
|
+
else if type == .mobility {
|
|
691
|
+
let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
692
|
+
.walkingSpeed,
|
|
693
|
+
.walkingStepLength,
|
|
694
|
+
.walkingAsymmetryPercentage,
|
|
695
|
+
.walkingDoubleSupportPercentage,
|
|
696
|
+
.stairAscentSpeed,
|
|
697
|
+
.sixMinuteWalkTestDistance
|
|
698
|
+
]
|
|
699
|
+
|
|
700
|
+
// Check all 6 mobility types
|
|
701
|
+
var mobilityAuthorizedCount = 0
|
|
702
|
+
|
|
703
|
+
// Create a nested group for mobility checks
|
|
704
|
+
let mobilityGroup = DispatchGroup()
|
|
705
|
+
|
|
706
|
+
for identifier in mobilityIdentifiers {
|
|
707
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
708
|
+
mobilityGroup.enter()
|
|
709
|
+
let readSet = Set<HKObjectType>([quantityType])
|
|
710
|
+
healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
|
|
711
|
+
defer { mobilityGroup.leave() }
|
|
712
|
+
|
|
713
|
+
if error == nil && status == .unnecessary {
|
|
714
|
+
lock.lock()
|
|
715
|
+
mobilityAuthorizedCount += 1
|
|
716
|
+
lock.unlock()
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Wait for all mobility checks, then determine if mobility is authorized
|
|
723
|
+
mobilityGroup.notify(queue: .main) {
|
|
724
|
+
lock.lock()
|
|
725
|
+
if mobilityAuthorizedCount == mobilityIdentifiers.count {
|
|
726
|
+
authorized.append(type)
|
|
727
|
+
} else {
|
|
728
|
+
denied.append(type)
|
|
729
|
+
}
|
|
730
|
+
lock.unlock()
|
|
731
|
+
}
|
|
732
|
+
} else {
|
|
733
|
+
guard let objectType = try? type.sampleType() else {
|
|
734
|
+
denied.append(type)
|
|
735
|
+
continue
|
|
410
736
|
}
|
|
411
737
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
738
|
+
group.enter()
|
|
739
|
+
let readSet = Set<HKObjectType>([objectType])
|
|
740
|
+
healthStore.getRequestStatusForAuthorization(toShare: Set<HKSampleType>(), read: readSet) { status, error in
|
|
741
|
+
defer { group.leave() }
|
|
742
|
+
|
|
743
|
+
if error != nil {
|
|
744
|
+
lock.lock(); denied.append(type); lock.unlock()
|
|
745
|
+
return
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
switch status {
|
|
749
|
+
case .unnecessary:
|
|
750
|
+
lock.lock(); authorized.append(type); lock.unlock()
|
|
751
|
+
case .shouldRequest, .unknown:
|
|
752
|
+
lock.lock(); denied.append(type); lock.unlock()
|
|
753
|
+
@unknown default:
|
|
754
|
+
lock.lock(); denied.append(type); lock.unlock()
|
|
755
|
+
}
|
|
419
756
|
}
|
|
420
757
|
}
|
|
421
758
|
}
|
|
@@ -478,6 +815,225 @@ final class Health {
|
|
|
478
815
|
}
|
|
479
816
|
}
|
|
480
817
|
}
|
|
818
|
+
|
|
819
|
+
private func processSleepSamples(_ samples: [HKCategorySample]) -> [[String: Any]] {
|
|
820
|
+
// Filter for detailed stage data only (Deep, REM, Core, Awake)
|
|
821
|
+
let detailedSamples = samples.filter { sample in
|
|
822
|
+
if #available(iOS 16.0, *) {
|
|
823
|
+
return sample.value == HKCategoryValueSleepAnalysis.asleepDeep.rawValue ||
|
|
824
|
+
sample.value == HKCategoryValueSleepAnalysis.asleepREM.rawValue ||
|
|
825
|
+
sample.value == HKCategoryValueSleepAnalysis.asleepCore.rawValue ||
|
|
826
|
+
sample.value == HKCategoryValueSleepAnalysis.awake.rawValue
|
|
827
|
+
} else {
|
|
828
|
+
// For older iOS, just process what's available
|
|
829
|
+
return true
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Sort by start date
|
|
834
|
+
let sortedSamples = detailedSamples.sorted { $0.startDate < $1.startDate }
|
|
835
|
+
|
|
836
|
+
// Collect ALL stage segments (raw data) - matching TypeScript structure
|
|
837
|
+
struct SleepSegment {
|
|
838
|
+
let start: Date
|
|
839
|
+
let end: Date
|
|
840
|
+
let stage: String
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
var allSegments: [SleepSegment] = []
|
|
844
|
+
|
|
845
|
+
for sample in sortedSamples {
|
|
846
|
+
let stageValue = getHealthKitStageConstantName(for: sample.value)
|
|
847
|
+
allSegments.append(SleepSegment(
|
|
848
|
+
start: sample.startDate,
|
|
849
|
+
end: sample.endDate,
|
|
850
|
+
stage: stageValue
|
|
851
|
+
))
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Group segments by SLEEP SESSION (not by day!)
|
|
855
|
+
// Sessions are separated by 30+ minute gaps
|
|
856
|
+
struct SleepSession {
|
|
857
|
+
let start: Date
|
|
858
|
+
let end: Date
|
|
859
|
+
let segments: [SleepSegment]
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
var sessions: [SleepSession] = []
|
|
863
|
+
var currentSession: [SleepSegment] = []
|
|
864
|
+
|
|
865
|
+
for segment in allSegments {
|
|
866
|
+
if currentSession.isEmpty {
|
|
867
|
+
currentSession.append(segment)
|
|
868
|
+
} else {
|
|
869
|
+
let lastSegment = currentSession.last!
|
|
870
|
+
let gapMinutes = segment.start.timeIntervalSince(lastSegment.end) / 60.0
|
|
871
|
+
|
|
872
|
+
if gapMinutes < 30 {
|
|
873
|
+
// Same session
|
|
874
|
+
currentSession.append(segment)
|
|
875
|
+
} else {
|
|
876
|
+
// New session (gap > 30 min)
|
|
877
|
+
if !currentSession.isEmpty {
|
|
878
|
+
sessions.append(SleepSession(
|
|
879
|
+
start: currentSession.first!.start,
|
|
880
|
+
end: currentSession.last!.end,
|
|
881
|
+
segments: currentSession
|
|
882
|
+
))
|
|
883
|
+
}
|
|
884
|
+
currentSession = [segment]
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Don't forget last session
|
|
890
|
+
if !currentSession.isEmpty {
|
|
891
|
+
sessions.append(SleepSession(
|
|
892
|
+
start: currentSession.first!.start,
|
|
893
|
+
end: currentSession.last!.end,
|
|
894
|
+
segments: currentSession
|
|
895
|
+
))
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Now attribute each session to the day you WOKE UP (end date)
|
|
899
|
+
struct DayData {
|
|
900
|
+
var sessions: [SleepSession]
|
|
901
|
+
var deepMinutes: Double
|
|
902
|
+
var remMinutes: Double
|
|
903
|
+
var coreMinutes: Double
|
|
904
|
+
var awakeMinutes: Double
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
var sleepByDate: [String: DayData] = [:]
|
|
908
|
+
|
|
909
|
+
for session in sessions {
|
|
910
|
+
// Wake-up date (local)
|
|
911
|
+
let calendar = Calendar.current
|
|
912
|
+
let wakeDate = calendar.dateComponents([.year, .month, .day], from: session.end)
|
|
913
|
+
let dateString = String(format: "%04d-%02d-%02d", wakeDate.year!, wakeDate.month!, wakeDate.day!)
|
|
914
|
+
|
|
915
|
+
// Initialize day data if needed
|
|
916
|
+
if sleepByDate[dateString] == nil {
|
|
917
|
+
sleepByDate[dateString] = DayData(
|
|
918
|
+
sessions: [],
|
|
919
|
+
deepMinutes: 0,
|
|
920
|
+
remMinutes: 0,
|
|
921
|
+
coreMinutes: 0,
|
|
922
|
+
awakeMinutes: 0
|
|
923
|
+
)
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
sleepByDate[dateString]!.sessions.append(session)
|
|
927
|
+
|
|
928
|
+
// Calculate minutes per stage for THIS session
|
|
929
|
+
for segment in session.segments {
|
|
930
|
+
let minutes = segment.end.timeIntervalSince(segment.start) / 60.0
|
|
931
|
+
|
|
932
|
+
if segment.stage == "HKCategoryValueSleepAnalysisAsleepDeep" {
|
|
933
|
+
sleepByDate[dateString]!.deepMinutes += minutes
|
|
934
|
+
} else if segment.stage == "HKCategoryValueSleepAnalysisAsleepREM" {
|
|
935
|
+
sleepByDate[dateString]!.remMinutes += minutes
|
|
936
|
+
} else if segment.stage == "HKCategoryValueSleepAnalysisAsleepCore" {
|
|
937
|
+
sleepByDate[dateString]!.coreMinutes += minutes
|
|
938
|
+
} else if segment.stage == "HKCategoryValueSleepAnalysisAwake" {
|
|
939
|
+
sleepByDate[dateString]!.awakeMinutes += minutes
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Convert to final format
|
|
945
|
+
var sleepData: [[String: Any]] = []
|
|
946
|
+
|
|
947
|
+
for (date, data) in sleepByDate {
|
|
948
|
+
let deepHours = data.deepMinutes / 60.0
|
|
949
|
+
let remHours = data.remMinutes / 60.0
|
|
950
|
+
let coreHours = data.coreMinutes / 60.0
|
|
951
|
+
let awakeHours = data.awakeMinutes / 60.0
|
|
952
|
+
let totalSleepHours = deepHours + remHours + coreHours
|
|
953
|
+
|
|
954
|
+
// Calculate time in bed from merged sessions (first start to last end)
|
|
955
|
+
var timeInBed = 0.0
|
|
956
|
+
if !data.sessions.isEmpty {
|
|
957
|
+
let bedtime = data.sessions.first!.start
|
|
958
|
+
let wakeTime = data.sessions.last!.end
|
|
959
|
+
timeInBed = wakeTime.timeIntervalSince(bedtime) / 3600.0
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Calculate efficiency
|
|
963
|
+
let efficiency = timeInBed > 0 ? Int(round((totalSleepHours / timeInBed) * 100)) : 0
|
|
964
|
+
|
|
965
|
+
// Map sessions to output format with segments matching TypeScript structure
|
|
966
|
+
let mergedSessions: [[String: Any]] = data.sessions.map { session in
|
|
967
|
+
let segments: [[String: Any]] = session.segments.map { segment in
|
|
968
|
+
return [
|
|
969
|
+
"start": isoFormatter.string(from: segment.start),
|
|
970
|
+
"end": isoFormatter.string(from: segment.end),
|
|
971
|
+
"stage": segment.stage
|
|
972
|
+
]
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
return [
|
|
976
|
+
"start": isoFormatter.string(from: session.start),
|
|
977
|
+
"end": isoFormatter.string(from: session.end),
|
|
978
|
+
"segments": segments
|
|
979
|
+
]
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
sleepData.append([
|
|
983
|
+
"date": date,
|
|
984
|
+
"totalSleepHours": round(totalSleepHours * 10) / 10,
|
|
985
|
+
"sleepSessions": data.sessions.count,
|
|
986
|
+
"deepSleep": round(deepHours * 10) / 10,
|
|
987
|
+
"remSleep": round(remHours * 10) / 10,
|
|
988
|
+
"coreSleep": round(coreHours * 10) / 10,
|
|
989
|
+
"awakeTime": round(awakeHours * 10) / 10,
|
|
990
|
+
"timeInBed": round(timeInBed * 10) / 10,
|
|
991
|
+
"efficiency": efficiency,
|
|
992
|
+
"mergedSessions": mergedSessions
|
|
993
|
+
])
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Sort by date
|
|
997
|
+
sleepData.sort { (a, b) -> Bool in
|
|
998
|
+
let dateA = a["date"] as! String
|
|
999
|
+
let dateB = b["date"] as! String
|
|
1000
|
+
return dateA < dateB
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return sleepData
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
private func getHealthKitStageConstantName(for value: Int) -> String {
|
|
1007
|
+
if #available(iOS 16.0, *) {
|
|
1008
|
+
switch value {
|
|
1009
|
+
case HKCategoryValueSleepAnalysis.asleepDeep.rawValue:
|
|
1010
|
+
return "HKCategoryValueSleepAnalysisAsleepDeep"
|
|
1011
|
+
case HKCategoryValueSleepAnalysis.asleepREM.rawValue:
|
|
1012
|
+
return "HKCategoryValueSleepAnalysisAsleepREM"
|
|
1013
|
+
case HKCategoryValueSleepAnalysis.asleepCore.rawValue:
|
|
1014
|
+
return "HKCategoryValueSleepAnalysisAsleepCore"
|
|
1015
|
+
case HKCategoryValueSleepAnalysis.awake.rawValue:
|
|
1016
|
+
return "HKCategoryValueSleepAnalysisAwake"
|
|
1017
|
+
case HKCategoryValueSleepAnalysis.inBed.rawValue:
|
|
1018
|
+
return "HKCategoryValueSleepAnalysisInBed"
|
|
1019
|
+
case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue:
|
|
1020
|
+
return "HKCategoryValueSleepAnalysisAsleepUnspecified"
|
|
1021
|
+
default:
|
|
1022
|
+
return "HKCategoryValueSleepAnalysisUnknown"
|
|
1023
|
+
}
|
|
1024
|
+
} else {
|
|
1025
|
+
switch value {
|
|
1026
|
+
case HKCategoryValueSleepAnalysis.inBed.rawValue:
|
|
1027
|
+
return "HKCategoryValueSleepAnalysisInBed"
|
|
1028
|
+
case HKCategoryValueSleepAnalysis.asleep.rawValue:
|
|
1029
|
+
return "HKCategoryValueSleepAnalysisAsleep"
|
|
1030
|
+
case HKCategoryValueSleepAnalysis.awake.rawValue:
|
|
1031
|
+
return "HKCategoryValueSleepAnalysisAwake"
|
|
1032
|
+
default:
|
|
1033
|
+
return "HKCategoryValueSleepAnalysisUnknown"
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
481
1037
|
|
|
482
1038
|
private func unit(for identifier: String?, dataType: HealthDataType) -> HKUnit {
|
|
483
1039
|
guard let identifier = identifier else {
|
|
@@ -505,8 +1061,77 @@ final class Health {
|
|
|
505
1061
|
private func objectTypes(for dataTypes: [HealthDataType]) throws -> Set<HKObjectType> {
|
|
506
1062
|
var set = Set<HKObjectType>()
|
|
507
1063
|
for dataType in dataTypes {
|
|
508
|
-
|
|
509
|
-
|
|
1064
|
+
// Special handling for activity - expand into all activity-related HealthKit types
|
|
1065
|
+
if dataType == .activity {
|
|
1066
|
+
let activityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1067
|
+
.stepCount,
|
|
1068
|
+
.distanceWalkingRunning,
|
|
1069
|
+
.flightsClimbed,
|
|
1070
|
+
.activeEnergyBurned,
|
|
1071
|
+
.appleExerciseTime
|
|
1072
|
+
]
|
|
1073
|
+
|
|
1074
|
+
for identifier in activityIdentifiers {
|
|
1075
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1076
|
+
set.insert(quantityType)
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Add category type for stand hours
|
|
1081
|
+
if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
|
|
1082
|
+
set.insert(standHourType)
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
// Special handling for heart - expand into all 6 HealthKit types
|
|
1086
|
+
else if dataType == .heart {
|
|
1087
|
+
let heartIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1088
|
+
.heartRate,
|
|
1089
|
+
.restingHeartRate,
|
|
1090
|
+
.vo2Max,
|
|
1091
|
+
.heartRateVariabilitySDNN,
|
|
1092
|
+
.oxygenSaturation,
|
|
1093
|
+
.respiratoryRate
|
|
1094
|
+
]
|
|
1095
|
+
|
|
1096
|
+
for identifier in heartIdentifiers {
|
|
1097
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1098
|
+
set.insert(quantityType)
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
// Special handling for body - expand into body composition types
|
|
1103
|
+
else if dataType == .body {
|
|
1104
|
+
let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1105
|
+
.bodyMass,
|
|
1106
|
+
.bodyFatPercentage
|
|
1107
|
+
]
|
|
1108
|
+
|
|
1109
|
+
for identifier in bodyIdentifiers {
|
|
1110
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1111
|
+
set.insert(quantityType)
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
// Special handling for mobility - expand into all 6 HealthKit types
|
|
1116
|
+
else if dataType == .mobility {
|
|
1117
|
+
let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1118
|
+
.walkingSpeed,
|
|
1119
|
+
.walkingStepLength,
|
|
1120
|
+
.walkingAsymmetryPercentage,
|
|
1121
|
+
.walkingDoubleSupportPercentage,
|
|
1122
|
+
.stairAscentSpeed,
|
|
1123
|
+
.sixMinuteWalkTestDistance
|
|
1124
|
+
]
|
|
1125
|
+
|
|
1126
|
+
for identifier in mobilityIdentifiers {
|
|
1127
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1128
|
+
set.insert(quantityType)
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
} else {
|
|
1132
|
+
let type = try dataType.sampleType()
|
|
1133
|
+
set.insert(type)
|
|
1134
|
+
}
|
|
510
1135
|
}
|
|
511
1136
|
return set
|
|
512
1137
|
}
|
|
@@ -514,9 +1139,925 @@ final class Health {
|
|
|
514
1139
|
private func sampleTypes(for dataTypes: [HealthDataType]) throws -> Set<HKSampleType> {
|
|
515
1140
|
var set = Set<HKSampleType>()
|
|
516
1141
|
for dataType in dataTypes {
|
|
517
|
-
|
|
518
|
-
|
|
1142
|
+
// Special handling for activity - expand into all activity-related HealthKit types
|
|
1143
|
+
if dataType == .activity {
|
|
1144
|
+
let activityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1145
|
+
.stepCount,
|
|
1146
|
+
.distanceWalkingRunning,
|
|
1147
|
+
.flightsClimbed,
|
|
1148
|
+
.activeEnergyBurned,
|
|
1149
|
+
.appleExerciseTime
|
|
1150
|
+
]
|
|
1151
|
+
|
|
1152
|
+
for identifier in activityIdentifiers {
|
|
1153
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1154
|
+
set.insert(quantityType)
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Add category type for stand hours
|
|
1159
|
+
if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
|
|
1160
|
+
set.insert(standHourType)
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
// Special handling for heart - expand into all 6 HealthKit types
|
|
1164
|
+
else if dataType == .heart {
|
|
1165
|
+
let heartIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1166
|
+
.heartRate,
|
|
1167
|
+
.restingHeartRate,
|
|
1168
|
+
.vo2Max,
|
|
1169
|
+
.heartRateVariabilitySDNN,
|
|
1170
|
+
.oxygenSaturation,
|
|
1171
|
+
.respiratoryRate
|
|
1172
|
+
]
|
|
1173
|
+
|
|
1174
|
+
for identifier in heartIdentifiers {
|
|
1175
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1176
|
+
set.insert(quantityType)
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
// Special handling for body - expand into body composition types
|
|
1181
|
+
else if dataType == .body {
|
|
1182
|
+
let bodyIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1183
|
+
.bodyMass,
|
|
1184
|
+
.bodyFatPercentage
|
|
1185
|
+
]
|
|
1186
|
+
|
|
1187
|
+
for identifier in bodyIdentifiers {
|
|
1188
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1189
|
+
set.insert(quantityType)
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
// Special handling for mobility - expand into all 6 HealthKit types
|
|
1194
|
+
else if dataType == .mobility {
|
|
1195
|
+
let mobilityIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
1196
|
+
.walkingSpeed,
|
|
1197
|
+
.walkingStepLength,
|
|
1198
|
+
.walkingAsymmetryPercentage,
|
|
1199
|
+
.walkingDoubleSupportPercentage,
|
|
1200
|
+
.stairAscentSpeed,
|
|
1201
|
+
.sixMinuteWalkTestDistance
|
|
1202
|
+
]
|
|
1203
|
+
|
|
1204
|
+
for identifier in mobilityIdentifiers {
|
|
1205
|
+
if let quantityType = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
1206
|
+
set.insert(quantityType)
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
} else {
|
|
1210
|
+
let type = try dataType.sampleType() as HKSampleType
|
|
1211
|
+
set.insert(type)
|
|
1212
|
+
}
|
|
519
1213
|
}
|
|
520
1214
|
return set
|
|
521
1215
|
}
|
|
1216
|
+
|
|
1217
|
+
// MARK: - Body Data Processing
|
|
1218
|
+
|
|
1219
|
+
private func processBodyData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
|
|
1220
|
+
let calendar = Calendar.current
|
|
1221
|
+
|
|
1222
|
+
// Define body composition quantity types
|
|
1223
|
+
let bodyTypes: [(HKQuantityTypeIdentifier, String)] = [
|
|
1224
|
+
(.bodyMass, "bodyMass"),
|
|
1225
|
+
(.bodyFatPercentage, "bodyFatPercentage")
|
|
1226
|
+
]
|
|
1227
|
+
|
|
1228
|
+
// Get all dates in the range
|
|
1229
|
+
var dates: [Date] = []
|
|
1230
|
+
var currentDate = calendar.startOfDay(for: startDate)
|
|
1231
|
+
let endOfDay = calendar.startOfDay(for: endDate)
|
|
1232
|
+
|
|
1233
|
+
while currentDate <= endOfDay {
|
|
1234
|
+
dates.append(currentDate)
|
|
1235
|
+
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Dictionary to store body data by date
|
|
1239
|
+
var bodyDataByDate: [String: [String: Any]] = [:]
|
|
1240
|
+
|
|
1241
|
+
// Initialize dates with empty dictionaries
|
|
1242
|
+
let dateFormatter = ISO8601DateFormatter()
|
|
1243
|
+
dateFormatter.formatOptions = [.withFullDate]
|
|
1244
|
+
|
|
1245
|
+
for date in dates {
|
|
1246
|
+
let dateStr = dateFormatter.string(from: date)
|
|
1247
|
+
bodyDataByDate[dateStr] = ["date": dateStr]
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
let group = DispatchGroup()
|
|
1251
|
+
|
|
1252
|
+
// Query each body type
|
|
1253
|
+
for (identifier, _) in bodyTypes {
|
|
1254
|
+
guard let quantityType = HKObjectType.quantityType(forIdentifier: identifier) else {
|
|
1255
|
+
continue
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
group.enter()
|
|
1259
|
+
|
|
1260
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
|
1261
|
+
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
|
1262
|
+
|
|
1263
|
+
let query = HKSampleQuery(sampleType: quantityType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sortDescriptor]) { _, samples, error in
|
|
1264
|
+
defer { group.leave() }
|
|
1265
|
+
|
|
1266
|
+
// Don't fail the entire request if one body type fails
|
|
1267
|
+
// Just skip this type and continue with others
|
|
1268
|
+
if error != nil {
|
|
1269
|
+
return
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
guard let samples = samples as? [HKQuantitySample] else {
|
|
1273
|
+
return
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Group samples by date and take the last measurement of each day
|
|
1277
|
+
var samplesByDate: [String: HKQuantitySample] = [:]
|
|
1278
|
+
|
|
1279
|
+
for sample in samples {
|
|
1280
|
+
let sampleDate = calendar.startOfDay(for: sample.startDate)
|
|
1281
|
+
let dateStr = dateFormatter.string(from: sampleDate)
|
|
1282
|
+
|
|
1283
|
+
// Keep the last (most recent) sample for each date
|
|
1284
|
+
if let existingSample = samplesByDate[dateStr] {
|
|
1285
|
+
if sample.startDate > existingSample.startDate {
|
|
1286
|
+
samplesByDate[dateStr] = sample
|
|
1287
|
+
}
|
|
1288
|
+
} else {
|
|
1289
|
+
samplesByDate[dateStr] = sample
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Process the last measurement for each date
|
|
1294
|
+
for (dateStr, sample) in samplesByDate {
|
|
1295
|
+
var dayData = bodyDataByDate[dateStr] ?? ["date": dateStr]
|
|
1296
|
+
|
|
1297
|
+
switch identifier {
|
|
1298
|
+
case .bodyMass:
|
|
1299
|
+
// Convert kg to lbs: kg * 2.20462
|
|
1300
|
+
let valueInKg = sample.quantity.doubleValue(for: HKUnit.gramUnit(with: .kilo))
|
|
1301
|
+
let valueInLbs = valueInKg * 2.20462
|
|
1302
|
+
dayData["weight"] = round(valueInLbs * 10) / 10 // 1 decimal place
|
|
1303
|
+
|
|
1304
|
+
case .bodyFatPercentage:
|
|
1305
|
+
// Convert decimal to percentage: 0.25 -> 25%
|
|
1306
|
+
let valueInDecimal = sample.quantity.doubleValue(for: HKUnit.percent())
|
|
1307
|
+
dayData["bodyFat"] = round(valueInDecimal * 10) / 10 // 1 decimal place
|
|
1308
|
+
|
|
1309
|
+
default:
|
|
1310
|
+
break
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
bodyDataByDate[dateStr] = dayData
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
healthStore.execute(query)
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
group.notify(queue: .main) {
|
|
1321
|
+
// Convert to array and sort by date
|
|
1322
|
+
let sortedData = bodyDataByDate.values
|
|
1323
|
+
.sorted { dict1, dict2 in
|
|
1324
|
+
guard let date1Str = dict1["date"] as? String,
|
|
1325
|
+
let date2Str = dict2["date"] as? String else {
|
|
1326
|
+
return false
|
|
1327
|
+
}
|
|
1328
|
+
return date1Str < date2Str
|
|
1329
|
+
}
|
|
1330
|
+
.filter { dict in
|
|
1331
|
+
// Only include dates that have at least one body measurement
|
|
1332
|
+
return dict.count > 1 // More than just the "date" field
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
completion(.success(sortedData))
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// MARK: - Mobility Data Processing
|
|
1340
|
+
|
|
1341
|
+
private func processMobilityData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
|
|
1342
|
+
let calendar = Calendar.current
|
|
1343
|
+
|
|
1344
|
+
// Define all mobility quantity types
|
|
1345
|
+
let mobilityTypes: [(HKQuantityTypeIdentifier, String)] = [
|
|
1346
|
+
(.walkingSpeed, "walkingSpeed"),
|
|
1347
|
+
(.walkingStepLength, "walkingStepLength"),
|
|
1348
|
+
(.walkingAsymmetryPercentage, "walkingAsymmetry"),
|
|
1349
|
+
(.walkingDoubleSupportPercentage, "walkingDoubleSupportTime"),
|
|
1350
|
+
(.stairAscentSpeed, "stairSpeed"),
|
|
1351
|
+
(.sixMinuteWalkTestDistance, "sixMinuteWalkDistance")
|
|
1352
|
+
]
|
|
1353
|
+
|
|
1354
|
+
let group = DispatchGroup()
|
|
1355
|
+
let lock = NSLock()
|
|
1356
|
+
|
|
1357
|
+
// Maps to store values by date for each metric
|
|
1358
|
+
var walkingSpeedMap: [String: [Double]] = [:]
|
|
1359
|
+
var stepLengthMap: [String: [Double]] = [:]
|
|
1360
|
+
var asymmetryMap: [String: [Double]] = [:]
|
|
1361
|
+
var doubleSupportMap: [String: [Double]] = [:]
|
|
1362
|
+
var stairSpeedMap: [String: [Double]] = [:]
|
|
1363
|
+
var sixMinWalkMap: [String: [Double]] = [:]
|
|
1364
|
+
|
|
1365
|
+
var errors: [Error] = []
|
|
1366
|
+
|
|
1367
|
+
// Query each mobility metric
|
|
1368
|
+
for (typeIdentifier, key) in mobilityTypes {
|
|
1369
|
+
guard let quantityType = HKObjectType.quantityType(forIdentifier: typeIdentifier) else {
|
|
1370
|
+
continue
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
group.enter()
|
|
1374
|
+
|
|
1375
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1376
|
+
let query = HKSampleQuery(sampleType: quantityType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1377
|
+
defer { group.leave() }
|
|
1378
|
+
|
|
1379
|
+
if let error = error {
|
|
1380
|
+
lock.lock()
|
|
1381
|
+
errors.append(error)
|
|
1382
|
+
lock.unlock()
|
|
1383
|
+
return
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else {
|
|
1387
|
+
return
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// Process samples and group by date
|
|
1391
|
+
for sample in quantitySamples {
|
|
1392
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1393
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1394
|
+
|
|
1395
|
+
var value: Double = 0.0
|
|
1396
|
+
|
|
1397
|
+
// Convert values to match XML parser units
|
|
1398
|
+
switch typeIdentifier {
|
|
1399
|
+
case .walkingSpeed:
|
|
1400
|
+
// Convert to mph (HealthKit stores in m/s)
|
|
1401
|
+
value = sample.quantity.doubleValue(for: HKUnit.meter().unitDivided(by: HKUnit.second())) * 2.23694
|
|
1402
|
+
lock.lock()
|
|
1403
|
+
if walkingSpeedMap[dateString] == nil {
|
|
1404
|
+
walkingSpeedMap[dateString] = []
|
|
1405
|
+
}
|
|
1406
|
+
walkingSpeedMap[dateString]?.append(value)
|
|
1407
|
+
lock.unlock()
|
|
1408
|
+
|
|
1409
|
+
case .walkingStepLength:
|
|
1410
|
+
// Convert to inches (HealthKit stores in meters)
|
|
1411
|
+
value = sample.quantity.doubleValue(for: HKUnit.meter()) * 39.3701
|
|
1412
|
+
lock.lock()
|
|
1413
|
+
if stepLengthMap[dateString] == nil {
|
|
1414
|
+
stepLengthMap[dateString] = []
|
|
1415
|
+
}
|
|
1416
|
+
stepLengthMap[dateString]?.append(value)
|
|
1417
|
+
lock.unlock()
|
|
1418
|
+
|
|
1419
|
+
case .walkingAsymmetryPercentage:
|
|
1420
|
+
// Convert to percentage (HealthKit stores as decimal)
|
|
1421
|
+
value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
|
|
1422
|
+
lock.lock()
|
|
1423
|
+
if asymmetryMap[dateString] == nil {
|
|
1424
|
+
asymmetryMap[dateString] = []
|
|
1425
|
+
}
|
|
1426
|
+
asymmetryMap[dateString]?.append(value)
|
|
1427
|
+
lock.unlock()
|
|
1428
|
+
|
|
1429
|
+
case .walkingDoubleSupportPercentage:
|
|
1430
|
+
// Convert to percentage (HealthKit stores as decimal)
|
|
1431
|
+
value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
|
|
1432
|
+
lock.lock()
|
|
1433
|
+
if doubleSupportMap[dateString] == nil {
|
|
1434
|
+
doubleSupportMap[dateString] = []
|
|
1435
|
+
}
|
|
1436
|
+
doubleSupportMap[dateString]?.append(value)
|
|
1437
|
+
lock.unlock()
|
|
1438
|
+
|
|
1439
|
+
case .stairAscentSpeed:
|
|
1440
|
+
// Convert to ft/s (HealthKit stores in m/s)
|
|
1441
|
+
value = sample.quantity.doubleValue(for: HKUnit.meter().unitDivided(by: HKUnit.second())) * 3.28084
|
|
1442
|
+
lock.lock()
|
|
1443
|
+
if stairSpeedMap[dateString] == nil {
|
|
1444
|
+
stairSpeedMap[dateString] = []
|
|
1445
|
+
}
|
|
1446
|
+
stairSpeedMap[dateString]?.append(value)
|
|
1447
|
+
lock.unlock()
|
|
1448
|
+
|
|
1449
|
+
case .sixMinuteWalkTestDistance:
|
|
1450
|
+
// Convert to yards (HealthKit stores in meters)
|
|
1451
|
+
value = sample.quantity.doubleValue(for: HKUnit.meter()) * 1.09361
|
|
1452
|
+
lock.lock()
|
|
1453
|
+
if sixMinWalkMap[dateString] == nil {
|
|
1454
|
+
sixMinWalkMap[dateString] = []
|
|
1455
|
+
}
|
|
1456
|
+
sixMinWalkMap[dateString]?.append(value)
|
|
1457
|
+
lock.unlock()
|
|
1458
|
+
|
|
1459
|
+
default:
|
|
1460
|
+
break
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
healthStore.execute(query)
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
group.notify(queue: .main) {
|
|
1469
|
+
if !errors.isEmpty {
|
|
1470
|
+
completion(.failure(errors.first!))
|
|
1471
|
+
return
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// Collect all unique dates
|
|
1475
|
+
var allDates = Set<String>()
|
|
1476
|
+
allDates.formUnion(walkingSpeedMap.keys)
|
|
1477
|
+
allDates.formUnion(stepLengthMap.keys)
|
|
1478
|
+
allDates.formUnion(asymmetryMap.keys)
|
|
1479
|
+
allDates.formUnion(doubleSupportMap.keys)
|
|
1480
|
+
allDates.formUnion(stairSpeedMap.keys)
|
|
1481
|
+
allDates.formUnion(sixMinWalkMap.keys)
|
|
1482
|
+
|
|
1483
|
+
// Create mobility data array with aggregated daily averages
|
|
1484
|
+
var mobilityData: [[String: Any]] = []
|
|
1485
|
+
|
|
1486
|
+
for date in allDates.sorted() {
|
|
1487
|
+
var result: [String: Any] = ["date": date]
|
|
1488
|
+
|
|
1489
|
+
// Average all measurements for the day
|
|
1490
|
+
if let values = walkingSpeedMap[date], !values.isEmpty {
|
|
1491
|
+
let average = values.reduce(0.0, +) / Double(values.count)
|
|
1492
|
+
result["walkingSpeed"] = round(average * 100) / 100
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
if let values = stepLengthMap[date], !values.isEmpty {
|
|
1496
|
+
let average = values.reduce(0.0, +) / Double(values.count)
|
|
1497
|
+
result["walkingStepLength"] = round(average * 10) / 10
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
if let values = asymmetryMap[date], !values.isEmpty {
|
|
1501
|
+
let average = values.reduce(0.0, +) / Double(values.count)
|
|
1502
|
+
result["walkingAsymmetry"] = round(average * 10) / 10
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
if let values = doubleSupportMap[date], !values.isEmpty {
|
|
1506
|
+
let average = values.reduce(0.0, +) / Double(values.count)
|
|
1507
|
+
result["walkingDoubleSupportTime"] = round(average * 10) / 10
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if let values = stairSpeedMap[date], !values.isEmpty {
|
|
1511
|
+
let average = values.reduce(0.0, +) / Double(values.count)
|
|
1512
|
+
result["stairSpeed"] = round(average * 100) / 100
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
if let values = sixMinWalkMap[date], !values.isEmpty {
|
|
1516
|
+
let average = values.reduce(0.0, +) / Double(values.count)
|
|
1517
|
+
result["sixMinuteWalkDistance"] = round(average * 10) / 10
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
mobilityData.append(result)
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
completion(.success(mobilityData))
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// MARK: - Activity Data Processing
|
|
1528
|
+
|
|
1529
|
+
private func processActivityData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
|
|
1530
|
+
let calendar = Calendar.current
|
|
1531
|
+
let group = DispatchGroup()
|
|
1532
|
+
let lock = NSLock()
|
|
1533
|
+
|
|
1534
|
+
// Maps to store values by date for each metric
|
|
1535
|
+
var stepsMap: [String: Double] = [:]
|
|
1536
|
+
var distanceMap: [String: Double] = [:]
|
|
1537
|
+
var flightsMap: [String: Double] = [:]
|
|
1538
|
+
var activeEnergyMap: [String: Double] = [:]
|
|
1539
|
+
var exerciseMinutesMap: [String: Double] = [:]
|
|
1540
|
+
var standHoursMap: [String: Int] = [:]
|
|
1541
|
+
|
|
1542
|
+
var errors: [Error] = []
|
|
1543
|
+
|
|
1544
|
+
// === STEPS ===
|
|
1545
|
+
if let stepsType = HKObjectType.quantityType(forIdentifier: .stepCount) {
|
|
1546
|
+
group.enter()
|
|
1547
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1548
|
+
let query = HKSampleQuery(sampleType: stepsType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1549
|
+
defer { group.leave() }
|
|
1550
|
+
|
|
1551
|
+
if let error = error {
|
|
1552
|
+
lock.lock()
|
|
1553
|
+
errors.append(error)
|
|
1554
|
+
lock.unlock()
|
|
1555
|
+
return
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1559
|
+
|
|
1560
|
+
for sample in quantitySamples {
|
|
1561
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1562
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1563
|
+
|
|
1564
|
+
let steps = sample.quantity.doubleValue(for: HKUnit.count())
|
|
1565
|
+
lock.lock()
|
|
1566
|
+
stepsMap[dateString] = (stepsMap[dateString] ?? 0) + steps
|
|
1567
|
+
lock.unlock()
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
healthStore.execute(query)
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// === DISTANCE (Walking + Running) ===
|
|
1574
|
+
if let distanceType = HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning) {
|
|
1575
|
+
group.enter()
|
|
1576
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1577
|
+
let query = HKSampleQuery(sampleType: distanceType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1578
|
+
defer { group.leave() }
|
|
1579
|
+
|
|
1580
|
+
if let error = error {
|
|
1581
|
+
lock.lock()
|
|
1582
|
+
errors.append(error)
|
|
1583
|
+
lock.unlock()
|
|
1584
|
+
return
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1588
|
+
|
|
1589
|
+
for sample in quantitySamples {
|
|
1590
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1591
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1592
|
+
|
|
1593
|
+
// Convert to miles (HealthKit stores in meters)
|
|
1594
|
+
let distanceMeters = sample.quantity.doubleValue(for: HKUnit.meter())
|
|
1595
|
+
let distanceMiles = distanceMeters * 0.000621371
|
|
1596
|
+
lock.lock()
|
|
1597
|
+
distanceMap[dateString] = (distanceMap[dateString] ?? 0) + distanceMiles
|
|
1598
|
+
lock.unlock()
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
healthStore.execute(query)
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// === FLIGHTS CLIMBED ===
|
|
1605
|
+
if let flightsType = HKObjectType.quantityType(forIdentifier: .flightsClimbed) {
|
|
1606
|
+
group.enter()
|
|
1607
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1608
|
+
let query = HKSampleQuery(sampleType: flightsType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1609
|
+
defer { group.leave() }
|
|
1610
|
+
|
|
1611
|
+
if let error = error {
|
|
1612
|
+
lock.lock()
|
|
1613
|
+
errors.append(error)
|
|
1614
|
+
lock.unlock()
|
|
1615
|
+
return
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1619
|
+
|
|
1620
|
+
for sample in quantitySamples {
|
|
1621
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1622
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1623
|
+
|
|
1624
|
+
let flights = sample.quantity.doubleValue(for: HKUnit.count())
|
|
1625
|
+
lock.lock()
|
|
1626
|
+
flightsMap[dateString] = (flightsMap[dateString] ?? 0) + flights
|
|
1627
|
+
lock.unlock()
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
healthStore.execute(query)
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// === ACTIVE ENERGY ===
|
|
1634
|
+
if let activeEnergyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned) {
|
|
1635
|
+
group.enter()
|
|
1636
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1637
|
+
let query = HKSampleQuery(sampleType: activeEnergyType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1638
|
+
defer { group.leave() }
|
|
1639
|
+
|
|
1640
|
+
if let error = error {
|
|
1641
|
+
lock.lock()
|
|
1642
|
+
errors.append(error)
|
|
1643
|
+
lock.unlock()
|
|
1644
|
+
return
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1648
|
+
|
|
1649
|
+
for sample in quantitySamples {
|
|
1650
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1651
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1652
|
+
|
|
1653
|
+
let calories = sample.quantity.doubleValue(for: HKUnit.kilocalorie())
|
|
1654
|
+
lock.lock()
|
|
1655
|
+
activeEnergyMap[dateString] = (activeEnergyMap[dateString] ?? 0) + calories
|
|
1656
|
+
lock.unlock()
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
healthStore.execute(query)
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// === EXERCISE MINUTES ===
|
|
1663
|
+
if let exerciseType = HKObjectType.quantityType(forIdentifier: .appleExerciseTime) {
|
|
1664
|
+
group.enter()
|
|
1665
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1666
|
+
let query = HKSampleQuery(sampleType: exerciseType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1667
|
+
defer { group.leave() }
|
|
1668
|
+
|
|
1669
|
+
if let error = error {
|
|
1670
|
+
lock.lock()
|
|
1671
|
+
errors.append(error)
|
|
1672
|
+
lock.unlock()
|
|
1673
|
+
return
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1677
|
+
|
|
1678
|
+
for sample in quantitySamples {
|
|
1679
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1680
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1681
|
+
|
|
1682
|
+
let minutes = sample.quantity.doubleValue(for: HKUnit.minute())
|
|
1683
|
+
lock.lock()
|
|
1684
|
+
exerciseMinutesMap[dateString] = (exerciseMinutesMap[dateString] ?? 0) + minutes
|
|
1685
|
+
lock.unlock()
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
healthStore.execute(query)
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// === STAND HOURS ===
|
|
1692
|
+
if let standHourType = HKObjectType.categoryType(forIdentifier: .appleStandHour) {
|
|
1693
|
+
group.enter()
|
|
1694
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1695
|
+
let query = HKSampleQuery(sampleType: standHourType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1696
|
+
defer { group.leave() }
|
|
1697
|
+
|
|
1698
|
+
if let error = error {
|
|
1699
|
+
lock.lock()
|
|
1700
|
+
errors.append(error)
|
|
1701
|
+
lock.unlock()
|
|
1702
|
+
return
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
guard let categorySamples = samples as? [HKCategorySample] else { return }
|
|
1706
|
+
|
|
1707
|
+
for sample in categorySamples {
|
|
1708
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1709
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1710
|
+
|
|
1711
|
+
// Value is HKCategoryValueAppleStandHour.stood (1) or .idle (0)
|
|
1712
|
+
if sample.value == HKCategoryValueAppleStandHour.stood.rawValue {
|
|
1713
|
+
lock.lock()
|
|
1714
|
+
standHoursMap[dateString] = (standHoursMap[dateString] ?? 0) + 1
|
|
1715
|
+
lock.unlock()
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
healthStore.execute(query)
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
group.notify(queue: .main) {
|
|
1723
|
+
// Only fail if there are errors AND no data was collected
|
|
1724
|
+
// This allows partial data to be returned if some permissions are denied
|
|
1725
|
+
if !errors.isEmpty {
|
|
1726
|
+
// Check if any data was actually collected
|
|
1727
|
+
let hasData = !stepsMap.isEmpty || !distanceMap.isEmpty || !flightsMap.isEmpty ||
|
|
1728
|
+
!activeEnergyMap.isEmpty || !exerciseMinutesMap.isEmpty || !standHoursMap.isEmpty
|
|
1729
|
+
|
|
1730
|
+
if !hasData {
|
|
1731
|
+
// No data at all, return the error
|
|
1732
|
+
completion(.failure(errors.first!))
|
|
1733
|
+
return
|
|
1734
|
+
}
|
|
1735
|
+
// If we have some data, continue and return what we have
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// Collect all unique dates that have any activity data
|
|
1739
|
+
var allDates = Set<String>()
|
|
1740
|
+
allDates.formUnion(stepsMap.keys)
|
|
1741
|
+
allDates.formUnion(distanceMap.keys)
|
|
1742
|
+
allDates.formUnion(flightsMap.keys)
|
|
1743
|
+
allDates.formUnion(activeEnergyMap.keys)
|
|
1744
|
+
allDates.formUnion(exerciseMinutesMap.keys)
|
|
1745
|
+
allDates.formUnion(standHoursMap.keys)
|
|
1746
|
+
|
|
1747
|
+
// Create activity data array matching the reference format
|
|
1748
|
+
let activityData: [[String: Any]] = allDates.sorted().compactMap { date in
|
|
1749
|
+
var result: [String: Any] = ["date": date]
|
|
1750
|
+
|
|
1751
|
+
// Add each metric if available, with proper rounding
|
|
1752
|
+
if let steps = stepsMap[date] {
|
|
1753
|
+
result["steps"] = Int(round(steps))
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
if let distance = distanceMap[date] {
|
|
1757
|
+
result["distance"] = round(distance * 100) / 100
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
if let flights = flightsMap[date] {
|
|
1761
|
+
result["flightsClimbed"] = Int(round(flights))
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
if let activeEnergy = activeEnergyMap[date] {
|
|
1765
|
+
result["activeEnergy"] = Int(round(activeEnergy))
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
if let exerciseMinutes = exerciseMinutesMap[date] {
|
|
1769
|
+
result["exerciseMinutes"] = Int(round(exerciseMinutes))
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
if let standHours = standHoursMap[date] {
|
|
1773
|
+
result["standHours"] = standHours
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
return result
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
completion(.success(activityData))
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// MARK: - Heart Data Processing
|
|
1784
|
+
|
|
1785
|
+
private func processHeartData(startDate: Date, endDate: Date, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
|
|
1786
|
+
let calendar = Calendar.current
|
|
1787
|
+
let group = DispatchGroup()
|
|
1788
|
+
let lock = NSLock()
|
|
1789
|
+
|
|
1790
|
+
// Maps to store values by date for each metric
|
|
1791
|
+
struct HeartRateStats {
|
|
1792
|
+
var sum: Double = 0
|
|
1793
|
+
var count: Int = 0
|
|
1794
|
+
var min: Double = 999
|
|
1795
|
+
var max: Double = 0
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
var heartRateMap: [String: HeartRateStats] = [:]
|
|
1799
|
+
var restingHRMap: [String: Double] = [:]
|
|
1800
|
+
var vo2MaxMap: [String: Double] = [:]
|
|
1801
|
+
var hrvMap: [String: Double] = [:]
|
|
1802
|
+
var spo2Map: [String: [Double]] = [:]
|
|
1803
|
+
var respirationRateMap: [String: [Double]] = [:]
|
|
1804
|
+
|
|
1805
|
+
var errors: [Error] = []
|
|
1806
|
+
|
|
1807
|
+
// === HEART RATE ===
|
|
1808
|
+
if let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) {
|
|
1809
|
+
group.enter()
|
|
1810
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1811
|
+
let query = HKSampleQuery(sampleType: heartRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1812
|
+
defer { group.leave() }
|
|
1813
|
+
|
|
1814
|
+
if let error = error {
|
|
1815
|
+
lock.lock()
|
|
1816
|
+
errors.append(error)
|
|
1817
|
+
lock.unlock()
|
|
1818
|
+
return
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1822
|
+
|
|
1823
|
+
for sample in quantitySamples {
|
|
1824
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1825
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1826
|
+
|
|
1827
|
+
let bpm = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
|
|
1828
|
+
lock.lock()
|
|
1829
|
+
var stats = heartRateMap[dateString] ?? HeartRateStats()
|
|
1830
|
+
stats.sum += bpm
|
|
1831
|
+
stats.count += 1
|
|
1832
|
+
stats.min = min(stats.min, bpm)
|
|
1833
|
+
stats.max = max(stats.max, bpm)
|
|
1834
|
+
heartRateMap[dateString] = stats
|
|
1835
|
+
lock.unlock()
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
healthStore.execute(query)
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// === RESTING HEART RATE ===
|
|
1842
|
+
if let restingHRType = HKObjectType.quantityType(forIdentifier: .restingHeartRate) {
|
|
1843
|
+
group.enter()
|
|
1844
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1845
|
+
let query = HKSampleQuery(sampleType: restingHRType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1846
|
+
defer { group.leave() }
|
|
1847
|
+
|
|
1848
|
+
if let error = error {
|
|
1849
|
+
lock.lock()
|
|
1850
|
+
errors.append(error)
|
|
1851
|
+
lock.unlock()
|
|
1852
|
+
return
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1856
|
+
|
|
1857
|
+
for sample in quantitySamples {
|
|
1858
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1859
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1860
|
+
|
|
1861
|
+
let bpm = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
|
|
1862
|
+
lock.lock()
|
|
1863
|
+
restingHRMap[dateString] = bpm // Take last measurement
|
|
1864
|
+
lock.unlock()
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
healthStore.execute(query)
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// === VO2 MAX ===
|
|
1871
|
+
if let vo2MaxType = HKObjectType.quantityType(forIdentifier: .vo2Max) {
|
|
1872
|
+
group.enter()
|
|
1873
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1874
|
+
let query = HKSampleQuery(sampleType: vo2MaxType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1875
|
+
defer { group.leave() }
|
|
1876
|
+
|
|
1877
|
+
if let error = error {
|
|
1878
|
+
lock.lock()
|
|
1879
|
+
errors.append(error)
|
|
1880
|
+
lock.unlock()
|
|
1881
|
+
return
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1885
|
+
|
|
1886
|
+
for sample in quantitySamples {
|
|
1887
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1888
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1889
|
+
|
|
1890
|
+
let vo2 = sample.quantity.doubleValue(for: HKUnit.literUnit(with: .milli).unitDivided(by: HKUnit.gramUnit(with: .kilo).unitMultiplied(by: HKUnit.minute())))
|
|
1891
|
+
lock.lock()
|
|
1892
|
+
vo2MaxMap[dateString] = vo2 // Take last measurement
|
|
1893
|
+
lock.unlock()
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
healthStore.execute(query)
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// === HRV (Heart Rate Variability SDNN) ===
|
|
1900
|
+
if let hrvType = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN) {
|
|
1901
|
+
group.enter()
|
|
1902
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1903
|
+
let query = HKSampleQuery(sampleType: hrvType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1904
|
+
defer { group.leave() }
|
|
1905
|
+
|
|
1906
|
+
if let error = error {
|
|
1907
|
+
lock.lock()
|
|
1908
|
+
errors.append(error)
|
|
1909
|
+
lock.unlock()
|
|
1910
|
+
return
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1914
|
+
|
|
1915
|
+
for sample in quantitySamples {
|
|
1916
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1917
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1918
|
+
|
|
1919
|
+
let hrv = sample.quantity.doubleValue(for: HKUnit.secondUnit(with: .milli))
|
|
1920
|
+
lock.lock()
|
|
1921
|
+
hrvMap[dateString] = hrv // Take last measurement
|
|
1922
|
+
lock.unlock()
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
healthStore.execute(query)
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// === SPO2 (Blood Oxygen Saturation) ===
|
|
1929
|
+
if let spo2Type = HKObjectType.quantityType(forIdentifier: .oxygenSaturation) {
|
|
1930
|
+
group.enter()
|
|
1931
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1932
|
+
let query = HKSampleQuery(sampleType: spo2Type, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1933
|
+
defer { group.leave() }
|
|
1934
|
+
|
|
1935
|
+
if let error = error {
|
|
1936
|
+
lock.lock()
|
|
1937
|
+
errors.append(error)
|
|
1938
|
+
lock.unlock()
|
|
1939
|
+
return
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1943
|
+
|
|
1944
|
+
for sample in quantitySamples {
|
|
1945
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1946
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1947
|
+
|
|
1948
|
+
// HealthKit stores as decimal (0.96), convert to percentage (96)
|
|
1949
|
+
let value = sample.quantity.doubleValue(for: HKUnit.percent()) * 100
|
|
1950
|
+
lock.lock()
|
|
1951
|
+
if spo2Map[dateString] == nil {
|
|
1952
|
+
spo2Map[dateString] = []
|
|
1953
|
+
}
|
|
1954
|
+
spo2Map[dateString]?.append(value)
|
|
1955
|
+
lock.unlock()
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
healthStore.execute(query)
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// === RESPIRATION RATE ===
|
|
1962
|
+
if let respirationRateType = HKObjectType.quantityType(forIdentifier: .respiratoryRate) {
|
|
1963
|
+
group.enter()
|
|
1964
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
|
|
1965
|
+
let query = HKSampleQuery(sampleType: respirationRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
1966
|
+
defer { group.leave() }
|
|
1967
|
+
|
|
1968
|
+
if let error = error {
|
|
1969
|
+
lock.lock()
|
|
1970
|
+
errors.append(error)
|
|
1971
|
+
lock.unlock()
|
|
1972
|
+
return
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
guard let quantitySamples = samples as? [HKQuantitySample] else { return }
|
|
1976
|
+
|
|
1977
|
+
for sample in quantitySamples {
|
|
1978
|
+
let dateComponents = calendar.dateComponents([.year, .month, .day], from: sample.startDate)
|
|
1979
|
+
let dateString = String(format: "%04d-%02d-%02d", dateComponents.year!, dateComponents.month!, dateComponents.day!)
|
|
1980
|
+
|
|
1981
|
+
let breathsPerMin = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
|
|
1982
|
+
lock.lock()
|
|
1983
|
+
if respirationRateMap[dateString] == nil {
|
|
1984
|
+
respirationRateMap[dateString] = []
|
|
1985
|
+
}
|
|
1986
|
+
respirationRateMap[dateString]?.append(breathsPerMin)
|
|
1987
|
+
lock.unlock()
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
healthStore.execute(query)
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
group.notify(queue: .main) {
|
|
1994
|
+
// Only fail if there are errors AND no data was collected
|
|
1995
|
+
// This allows partial data to be returned if some permissions are denied
|
|
1996
|
+
if !errors.isEmpty {
|
|
1997
|
+
// Check if any data was actually collected
|
|
1998
|
+
let hasData = !heartRateMap.isEmpty || !restingHRMap.isEmpty || !vo2MaxMap.isEmpty ||
|
|
1999
|
+
!hrvMap.isEmpty || !spo2Map.isEmpty || !respirationRateMap.isEmpty
|
|
2000
|
+
|
|
2001
|
+
if !hasData {
|
|
2002
|
+
// No data at all, return the error
|
|
2003
|
+
completion(.failure(errors.first!))
|
|
2004
|
+
return
|
|
2005
|
+
}
|
|
2006
|
+
// If we have some data, continue and return what we have
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// Collect all unique dates that have any heart data
|
|
2010
|
+
var allDates = Set<String>()
|
|
2011
|
+
allDates.formUnion(heartRateMap.keys)
|
|
2012
|
+
allDates.formUnion(restingHRMap.keys)
|
|
2013
|
+
allDates.formUnion(vo2MaxMap.keys)
|
|
2014
|
+
allDates.formUnion(hrvMap.keys)
|
|
2015
|
+
allDates.formUnion(spo2Map.keys)
|
|
2016
|
+
allDates.formUnion(respirationRateMap.keys)
|
|
2017
|
+
|
|
2018
|
+
// Create heart data array matching the TypeScript format
|
|
2019
|
+
let heartData: [[String: Any]] = allDates.sorted().compactMap { date in
|
|
2020
|
+
var result: [String: Any] = ["date": date]
|
|
2021
|
+
|
|
2022
|
+
// Heart Rate (avg, min, max, count)
|
|
2023
|
+
if let stats = heartRateMap[date] {
|
|
2024
|
+
result["avgBpm"] = Int(round(stats.sum / Double(stats.count)))
|
|
2025
|
+
result["minBpm"] = Int(round(stats.min))
|
|
2026
|
+
result["maxBpm"] = Int(round(stats.max))
|
|
2027
|
+
result["heartRateMeasurements"] = stats.count
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// Resting Heart Rate
|
|
2031
|
+
if let restingBpm = restingHRMap[date] {
|
|
2032
|
+
result["restingBpm"] = Int(round(restingBpm))
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// VO2 Max (rounded to 1 decimal)
|
|
2036
|
+
if let vo2 = vo2MaxMap[date] {
|
|
2037
|
+
result["vo2Max"] = round(vo2 * 10) / 10
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// HRV (rounded to 3 decimals)
|
|
2041
|
+
if let hrv = hrvMap[date] {
|
|
2042
|
+
result["hrv"] = round(hrv * 1000) / 1000
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// SpO2 (average)
|
|
2046
|
+
if let readings = spo2Map[date], !readings.isEmpty {
|
|
2047
|
+
let average = readings.reduce(0.0, +) / Double(readings.count)
|
|
2048
|
+
result["spo2"] = Int(round(average))
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// Respiration Rate (average, rounded to 1 decimal)
|
|
2052
|
+
if let readings = respirationRateMap[date], !readings.isEmpty {
|
|
2053
|
+
let average = readings.reduce(0.0, +) / Double(readings.count)
|
|
2054
|
+
result["respirationRate"] = round(average * 10) / 10
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
return result
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
completion(.success(heartData))
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
522
2063
|
}
|