@flomentumsolutions/capacitor-health-extended 0.1.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import UIKit
2
3
  import Capacitor
3
4
  import HealthKit
4
5
 
@@ -17,17 +18,13 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
17
18
  CAPPluginMethod(name: "openAppleHealthSettings", returnType: CAPPluginReturnPromise),
18
19
  CAPPluginMethod(name: "queryAggregated", returnType: CAPPluginReturnPromise),
19
20
  CAPPluginMethod(name: "queryWorkouts", returnType: CAPPluginReturnPromise),
20
- CAPPluginMethod(name: "queryLatestSample", returnType: CAPPluginReturnPromise),
21
- CAPPluginMethod(name: "queryWeight", returnType: CAPPluginReturnPromise),
22
- CAPPluginMethod(name: "queryHeight", returnType: CAPPluginReturnPromise),
23
- CAPPluginMethod(name: "queryHeartRate", returnType: CAPPluginReturnPromise),
24
- CAPPluginMethod(name: "querySteps", returnType: CAPPluginReturnPromise)
21
+ CAPPluginMethod(name: "queryLatestSample", returnType: CAPPluginReturnPromise)
25
22
  ]
26
23
 
27
24
  let healthStore = HKHealthStore()
28
25
 
29
26
  /// Serial queue to make route‑location mutations thread‑safe without locks
30
- private let routeSyncQueue = DispatchQueue(label: "com.flomentum.healthplugin.routeSync")
27
+ private let routeSyncQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.routeSync")
31
28
 
32
29
  @objc func isHealthAvailable(_ call: CAPPluginCall) {
33
30
  let isAvailable = HKHealthStore.isHealthDataAvailable()
@@ -153,6 +150,73 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
153
150
  healthStore.execute(query)
154
151
  return
155
152
  }
153
+ // ---- Derived total calories (active + basal) ----
154
+ if dataTypeString == "total-calories" {
155
+ queryLatestTotalCalories(call)
156
+ return
157
+ }
158
+ // ---- Special handling for sleep sessions (category samples) ----
159
+ if dataTypeString == "sleep" {
160
+ guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
161
+ call.reject("Sleep type not available")
162
+ return
163
+ }
164
+
165
+ let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
166
+ let predicate = HKQuery.predicateForSamples(withStart: Date.distantPast, end: Date(), options: .strictEndDate)
167
+
168
+ let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: 1, sortDescriptors: [sortDescriptor]) { _, samples, error in
169
+ guard let sleepSample = samples?.first as? HKCategorySample else {
170
+ if let error = error {
171
+ call.reject("Error fetching latest sleep sample", "NO_SAMPLE", error)
172
+ } else {
173
+ call.reject("No sleep sample found", "NO_SAMPLE")
174
+ }
175
+ return
176
+ }
177
+ let durationMinutes = sleepSample.endDate.timeIntervalSince(sleepSample.startDate) / 60
178
+ call.resolve([
179
+ "value": durationMinutes,
180
+ "timestamp": sleepSample.startDate.timeIntervalSince1970 * 1000,
181
+ "endTimestamp": sleepSample.endDate.timeIntervalSince1970 * 1000,
182
+ "unit": "min",
183
+ "metadata": ["state": sleepSample.value]
184
+ ])
185
+ }
186
+ healthStore.execute(query)
187
+ return
188
+ }
189
+ // ---- Special handling for mindfulness sessions (category samples) ----
190
+ if dataTypeString == "mindfulness" {
191
+ guard let mindfulType = HKObjectType.categoryType(forIdentifier: .mindfulSession) else {
192
+ call.reject("Mindfulness type not available")
193
+ return
194
+ }
195
+
196
+ let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
197
+ let predicate = HKQuery.predicateForSamples(withStart: Date.distantPast, end: Date(), options: .strictEndDate)
198
+
199
+ let query = HKSampleQuery(sampleType: mindfulType, predicate: predicate, limit: 1, sortDescriptors: [sortDescriptor]) { _, samples, error in
200
+ guard let mindfulSample = samples?.first as? HKCategorySample else {
201
+ if let error = error {
202
+ call.reject("Error fetching latest mindfulness sample", "NO_SAMPLE", error)
203
+ } else {
204
+ call.reject("No mindfulness sample found", "NO_SAMPLE")
205
+ }
206
+ return
207
+ }
208
+ let durationMinutes = mindfulSample.endDate.timeIntervalSince(mindfulSample.startDate) / 60
209
+ call.resolve([
210
+ "value": durationMinutes,
211
+ "timestamp": mindfulSample.startDate.timeIntervalSince1970 * 1000,
212
+ "endTimestamp": mindfulSample.endDate.timeIntervalSince1970 * 1000,
213
+ "unit": "min",
214
+ "metadata": ["value": mindfulSample.value]
215
+ ])
216
+ }
217
+ healthStore.execute(query)
218
+ return
219
+ }
156
220
  guard aggregateTypeToHKQuantityType(dataTypeString) != nil else {
157
221
  call.reject("Invalid data type")
158
222
  return
@@ -162,6 +226,10 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
162
226
  switch dataTypeString {
163
227
  case "heart-rate":
164
228
  return HKObjectType.quantityType(forIdentifier: .heartRate)
229
+ case "resting-heart-rate":
230
+ return HKObjectType.quantityType(forIdentifier: .restingHeartRate)
231
+ case "respiratory-rate":
232
+ return HKObjectType.quantityType(forIdentifier: .respiratoryRate)
165
233
  case "weight":
166
234
  return HKObjectType.quantityType(forIdentifier: .bodyMass)
167
235
  case "steps":
@@ -172,12 +240,28 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
172
240
  return HKObjectType.quantityType(forIdentifier: .height)
173
241
  case "distance":
174
242
  return HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning)
175
- case "active-calories":
176
- return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
177
- case "total-calories":
178
- return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
179
- case "blood-pressure":
180
- return nil // handled above
243
+ case "distance-cycling":
244
+ return HKObjectType.quantityType(forIdentifier: .distanceCycling)
245
+ case "active-calories":
246
+ return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
247
+ case "basal-calories":
248
+ return HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
249
+ case "blood-pressure":
250
+ return nil // handled above
251
+ case "oxygen-saturation":
252
+ return HKObjectType.quantityType(forIdentifier: .oxygenSaturation)
253
+ case "blood-glucose":
254
+ return HKObjectType.quantityType(forIdentifier: .bloodGlucose)
255
+ case "body-temperature":
256
+ return HKObjectType.quantityType(forIdentifier: .bodyTemperature)
257
+ case "basal-body-temperature":
258
+ return HKObjectType.quantityType(forIdentifier: .basalBodyTemperature)
259
+ case "body-fat":
260
+ return HKObjectType.quantityType(forIdentifier: .bodyFatPercentage)
261
+ case "flights-climbed":
262
+ return HKObjectType.quantityType(forIdentifier: .flightsClimbed)
263
+ case "exercise-time":
264
+ return HKObjectType.quantityType(forIdentifier: .appleExerciseTime)
181
265
  default:
182
266
  return nil
183
267
  }
@@ -209,16 +293,36 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
209
293
  var unit: HKUnit = .count()
210
294
  if dataTypeString == "heart-rate" {
211
295
  unit = HKUnit.count().unitDivided(by: HKUnit.minute())
296
+ } else if dataTypeString == "resting-heart-rate" {
297
+ unit = HKUnit.count().unitDivided(by: HKUnit.minute())
212
298
  } else if dataTypeString == "weight" {
213
299
  unit = .gramUnit(with: .kilo)
214
300
  } else if dataTypeString == "hrv" {
215
301
  unit = HKUnit.secondUnit(with: .milli)
216
302
  } else if dataTypeString == "distance" {
217
303
  unit = HKUnit.meter()
304
+ } else if dataTypeString == "distance-cycling" {
305
+ unit = HKUnit.meter()
218
306
  } else if dataTypeString == "active-calories" || dataTypeString == "total-calories" {
219
307
  unit = HKUnit.kilocalorie()
308
+ } else if dataTypeString == "basal-calories" {
309
+ unit = HKUnit.kilocalorie()
220
310
  } else if dataTypeString == "height" {
221
311
  unit = HKUnit.meter()
312
+ } else if dataTypeString == "oxygen-saturation" {
313
+ unit = HKUnit.percent()
314
+ } else if dataTypeString == "blood-glucose" {
315
+ unit = HKUnit(from: "mg/dL")
316
+ } else if dataTypeString == "body-temperature" || dataTypeString == "basal-body-temperature" {
317
+ unit = HKUnit.degreeCelsius()
318
+ } else if dataTypeString == "body-fat" {
319
+ unit = HKUnit.percent()
320
+ } else if dataTypeString == "flights-climbed" {
321
+ unit = HKUnit.count()
322
+ } else if dataTypeString == "exercise-time" {
323
+ unit = HKUnit.minute()
324
+ } else if dataTypeString == "respiratory-rate" {
325
+ unit = HKUnit.count().unitDivided(by: HKUnit.minute())
222
326
  }
223
327
  let value = quantitySample.quantity.doubleValue(for: unit)
224
328
  let timestamp = quantitySample.startDate.timeIntervalSince1970 * 1000
@@ -235,57 +339,6 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
235
339
  healthStore.execute(query)
236
340
  }
237
341
 
238
- // Convenience methods for specific data types
239
- @objc func queryWeight(_ call: CAPPluginCall) {
240
- queryLatestSampleWithType(call, dataType: "weight")
241
- }
242
-
243
- @objc func queryHeight(_ call: CAPPluginCall) {
244
- queryLatestSampleWithType(call, dataType: "height")
245
- }
246
-
247
- @objc func queryHeartRate(_ call: CAPPluginCall) {
248
- queryLatestSampleWithType(call, dataType: "heart-rate")
249
- }
250
-
251
- @objc func querySteps(_ call: CAPPluginCall) {
252
- queryLatestSampleWithType(call, dataType: "steps")
253
- }
254
-
255
- private func queryLatestSampleWithType(_ call: CAPPluginCall, dataType: String) {
256
- // Safely coerce the original options into a [String: Any] JSObject.
257
- let originalOptions = call.options as? [String: Any] ?? [:]
258
- var params = originalOptions
259
- params["dataType"] = dataType
260
-
261
- // Create a proxy CAPPluginCall using the CURRENT (Capacitor 6) designated initializer.
262
- // NOTE: The older init(callbackId:options:success:error:) is deprecated and *failable*,
263
- // so we use the newer initializer that requires a method name. Guard against failure.
264
- guard let proxyCall = CAPPluginCall(
265
- callbackId: call.callbackId,
266
- methodName: "queryLatestSample", // required in new API
267
- options: params,
268
- success: { result, _ in
269
- // Forward the resolved data back to the original JS caller.
270
- call.resolve(result?.data ?? [:])
271
- },
272
- error: { capError in
273
- // Forward the error to the original call in the legacy reject format.
274
- if let capError = capError {
275
- call.reject(capError.message, capError.code, capError.error, capError.data)
276
- } else {
277
- call.reject("Unknown native error")
278
- }
279
- }
280
- ) else {
281
- call.reject("Failed to create proxy call")
282
- return
283
- }
284
-
285
- // Delegate the actual HealthKit fetch to the common implementation.
286
- queryLatestSample(proxyCall)
287
- }
288
-
289
342
  @objc func openAppleHealthSettings(_ call: CAPPluginCall) {
290
343
  if let url = URL(string: UIApplication.openSettingsURLString) {
291
344
  DispatchQueue.main.async {
@@ -317,6 +370,8 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
317
370
  return [HKObjectType.workoutType()].compactMap{$0}
318
371
  case "READ_HEART_RATE":
319
372
  return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap{$0}
373
+ case "READ_RESTING_HEART_RATE":
374
+ return [HKObjectType.quantityType(forIdentifier: .restingHeartRate)].compactMap { $0 }
320
375
  case "READ_ROUTE":
321
376
  return [HKSeriesType.workoutRoute()].compactMap{$0}
322
377
  case "READ_DISTANCE":
@@ -335,6 +390,28 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
335
390
  HKObjectType.quantityType(forIdentifier: .bloodPressureSystolic),
336
391
  HKObjectType.quantityType(forIdentifier: .bloodPressureDiastolic)
337
392
  ].compactMap { $0 }
393
+ case "READ_RESPIRATORY_RATE":
394
+ return [HKObjectType.quantityType(forIdentifier: .respiratoryRate)].compactMap { $0 }
395
+ case "READ_OXYGEN_SATURATION":
396
+ return [HKObjectType.quantityType(forIdentifier: .oxygenSaturation)].compactMap { $0 }
397
+ case "READ_BLOOD_GLUCOSE":
398
+ return [HKObjectType.quantityType(forIdentifier: .bloodGlucose)].compactMap { $0 }
399
+ case "READ_BODY_TEMPERATURE":
400
+ return [HKObjectType.quantityType(forIdentifier: .bodyTemperature)].compactMap { $0 }
401
+ case "READ_BASAL_BODY_TEMPERATURE":
402
+ return [HKObjectType.quantityType(forIdentifier: .basalBodyTemperature)].compactMap { $0 }
403
+ case "READ_BODY_FAT":
404
+ return [HKObjectType.quantityType(forIdentifier: .bodyFatPercentage)].compactMap { $0 }
405
+ case "READ_FLOORS_CLIMBED":
406
+ return [HKObjectType.quantityType(forIdentifier: .flightsClimbed)].compactMap { $0 }
407
+ case "READ_BASAL_CALORIES":
408
+ return [
409
+ HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
410
+ ].compactMap { $0 }
411
+ case "READ_SLEEP":
412
+ return [HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!].compactMap { $0 }
413
+ case "READ_EXERCISE_TIME":
414
+ return [HKObjectType.quantityType(forIdentifier: .appleExerciseTime)].compactMap { $0 }
338
415
  // Add common alternative permission names
339
416
  case "steps":
340
417
  return [HKObjectType.quantityType(forIdentifier: .stepCount)].compactMap{$0}
@@ -371,6 +448,26 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
371
448
  HKObjectType.quantityType(forIdentifier: .bloodPressureSystolic),
372
449
  HKObjectType.quantityType(forIdentifier: .bloodPressureDiastolic)
373
450
  ].compactMap { $0 }
451
+ case "respiratory-rate":
452
+ return [HKObjectType.quantityType(forIdentifier: .respiratoryRate)].compactMap { $0 }
453
+ case "oxygen-saturation":
454
+ return [HKObjectType.quantityType(forIdentifier: .oxygenSaturation)].compactMap { $0 }
455
+ case "blood-glucose":
456
+ return [HKObjectType.quantityType(forIdentifier: .bloodGlucose)].compactMap { $0 }
457
+ case "body-temperature":
458
+ return [HKObjectType.quantityType(forIdentifier: .bodyTemperature)].compactMap { $0 }
459
+ case "basal-body-temperature":
460
+ return [HKObjectType.quantityType(forIdentifier: .basalBodyTemperature)].compactMap { $0 }
461
+ case "body-fat":
462
+ return [HKObjectType.quantityType(forIdentifier: .bodyFatPercentage)].compactMap { $0 }
463
+ case "flights-climbed":
464
+ return [HKObjectType.quantityType(forIdentifier: .flightsClimbed)].compactMap { $0 }
465
+ case "basal-calories":
466
+ return [HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)].compactMap { $0 }
467
+ case "exercise-time":
468
+ return [HKObjectType.quantityType(forIdentifier: .appleExerciseTime)].compactMap { $0 }
469
+ case "sleep":
470
+ return [HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!].compactMap { $0 }
374
471
  default:
375
472
  print("⚡️ [HealthPlugin] Unknown permission: \(permission)")
376
473
  return []
@@ -385,16 +482,38 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
385
482
  return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
386
483
  case "heart-rate":
387
484
  return HKObjectType.quantityType(forIdentifier: .heartRate)
485
+ case "resting-heart-rate":
486
+ return HKObjectType.quantityType(forIdentifier: .restingHeartRate)
388
487
  case "weight":
389
488
  return HKObjectType.quantityType(forIdentifier: .bodyMass)
390
489
  case "hrv":
391
490
  return HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)
392
491
  case "distance":
393
492
  return HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning) // pick one rep type
493
+ case "distance-cycling":
494
+ return HKObjectType.quantityType(forIdentifier: .distanceCycling)
394
495
  case "total-calories":
395
496
  return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
497
+ case "basal-calories":
498
+ return HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
396
499
  case "height":
397
500
  return HKObjectType.quantityType(forIdentifier: .height)
501
+ case "respiratory-rate":
502
+ return HKObjectType.quantityType(forIdentifier: .respiratoryRate)
503
+ case "oxygen-saturation":
504
+ return HKObjectType.quantityType(forIdentifier: .oxygenSaturation)
505
+ case "blood-glucose":
506
+ return HKObjectType.quantityType(forIdentifier: .bloodGlucose)
507
+ case "body-temperature":
508
+ return HKObjectType.quantityType(forIdentifier: .bodyTemperature)
509
+ case "basal-body-temperature":
510
+ return HKObjectType.quantityType(forIdentifier: .basalBodyTemperature)
511
+ case "body-fat":
512
+ return HKObjectType.quantityType(forIdentifier: .bodyFatPercentage)
513
+ case "flights-climbed":
514
+ return HKObjectType.quantityType(forIdentifier: .flightsClimbed)
515
+ case "exercise-time":
516
+ return HKObjectType.quantityType(forIdentifier: .appleExerciseTime)
398
517
  default:
399
518
  return nil
400
519
  }
@@ -406,13 +525,21 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
406
525
  let endDateString = call.getString("endDate"),
407
526
  let dataTypeString = call.getString("dataType"),
408
527
  let bucket = call.getString("bucket"),
409
- let startDate = self.isoDateFormatter.date(from: startDateString),
410
- let endDate = self.isoDateFormatter.date(from: endDateString) else {
528
+ let rawStartDate = self.isoDateFormatter.date(from: startDateString),
529
+ let rawEndDate = self.isoDateFormatter.date(from: endDateString) else {
411
530
  DispatchQueue.main.async {
412
531
  call.reject("Invalid parameters")
413
532
  }
414
533
  return
415
534
  }
535
+ let calendar = Calendar.current
536
+ var startDate = rawStartDate
537
+ var endDate = rawEndDate
538
+ if bucket == "day" {
539
+ startDate = calendar.startOfDay(for: rawStartDate)
540
+ let endDayStart = calendar.startOfDay(for: rawEndDate)
541
+ endDate = calendar.date(byAdding: .day, to: endDayStart) ?? endDayStart
542
+ }
416
543
  if dataTypeString == "mindfulness" {
417
544
  self.queryMindfulnessAggregated(startDate: startDate, endDate: endDate) { result, error in
418
545
  DispatchQueue.main.async {
@@ -423,6 +550,48 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
423
550
  }
424
551
  }
425
552
  }
553
+ } else if dataTypeString == "blood-pressure" {
554
+ guard bucket == "day" else {
555
+ DispatchQueue.main.async {
556
+ call.reject("Blood pressure aggregation only supports daily buckets")
557
+ }
558
+ return
559
+ }
560
+ self.queryBloodPressureAggregated(startDate: startDate, endDate: endDate) { result, error in
561
+ DispatchQueue.main.async {
562
+ if let error = error {
563
+ call.reject(error.localizedDescription)
564
+ } else if let result = result {
565
+ call.resolve(["aggregatedData": result])
566
+ }
567
+ }
568
+ }
569
+ } else if dataTypeString == "total-calories" {
570
+ guard let interval = calculateInterval(bucket: bucket) else {
571
+ DispatchQueue.main.async {
572
+ call.reject("Invalid bucket")
573
+ }
574
+ return
575
+ }
576
+ self.queryTotalCaloriesAggregated(startDate: startDate, endDate: endDate, interval: interval) { result, error in
577
+ DispatchQueue.main.async {
578
+ if let error = error {
579
+ call.reject(error.localizedDescription)
580
+ } else if let result = result {
581
+ call.resolve(["aggregatedData": result])
582
+ }
583
+ }
584
+ }
585
+ } else if dataTypeString == "sleep" {
586
+ self.querySleepAggregated(startDate: startDate, endDate: endDate) { result, error in
587
+ DispatchQueue.main.async {
588
+ if let error = error {
589
+ call.reject(error.localizedDescription)
590
+ } else if let result = result {
591
+ call.resolve(["aggregatedData": result])
592
+ }
593
+ }
594
+ }
426
595
  } else {
427
596
  let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
428
597
  guard let interval = calculateInterval(bucket: bucket) else {
@@ -441,7 +610,10 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
441
610
  switch dataType.aggregationStyle {
442
611
  case .cumulative:
443
612
  return .cumulativeSum
444
- case .discrete:
613
+ case .discrete,
614
+ .discreteArithmetic,
615
+ .discreteTemporallyWeighted,
616
+ .discreteEquivalentContinuousLevel:
445
617
  return .discreteAverage
446
618
  @unknown default:
447
619
  return .discreteAverage
@@ -471,12 +643,19 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
471
643
  let unit: HKUnit = {
472
644
  switch dataTypeString {
473
645
  case "steps": return .count()
474
- case "active-calories", "total-calories": return .kilocalorie()
475
- case "distance": return .meter()
646
+ case "active-calories", "total-calories", "basal-calories": return .kilocalorie()
647
+ case "distance", "distance-cycling": return .meter()
476
648
  case "weight": return .gramUnit(with: .kilo)
477
649
  case "height": return .meter()
478
- case "heart-rate": return HKUnit.count().unitDivided(by: HKUnit.minute())
650
+ case "heart-rate", "resting-heart-rate": return HKUnit.count().unitDivided(by: HKUnit.minute())
651
+ case "respiratory-rate": return HKUnit.count().unitDivided(by: HKUnit.minute())
479
652
  case "hrv": return HKUnit.secondUnit(with: .milli)
653
+ case "oxygen-saturation": return HKUnit.percent()
654
+ case "blood-glucose": return HKUnit(from: "mg/dL")
655
+ case "body-temperature", "basal-body-temperature": return HKUnit.degreeCelsius()
656
+ case "body-fat": return HKUnit.percent()
657
+ case "flights-climbed": return .count()
658
+ case "exercise-time": return HKUnit.minute()
480
659
  case "mindfulness": return HKUnit.second()
481
660
  default: return .count()
482
661
  }
@@ -532,6 +711,275 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
532
711
  }
533
712
  healthStore.execute(query)
534
713
  }
714
+
715
+ func querySleepAggregated(startDate: Date, endDate: Date, completion: @escaping ([[String: Any]]?, Error?) -> Void) {
716
+ guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
717
+ DispatchQueue.main.async {
718
+ completion(nil, NSError(domain: "HealthKit", code: -1, userInfo: [NSLocalizedDescriptionKey: "SleepAnalysis type unavailable"]))
719
+ }
720
+ return
721
+ }
722
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
723
+ let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
724
+ var dailyDurations: [Date: TimeInterval] = [:]
725
+ let calendar = Calendar.current
726
+ if let categorySamples = samples as? [HKCategorySample], error == nil {
727
+ for sample in categorySamples {
728
+ // Ignore in-bed samples; we care about actual sleep
729
+ if sample.value == HKCategoryValueSleepAnalysis.inBed.rawValue { continue }
730
+ let startOfDay = calendar.startOfDay(for: sample.startDate)
731
+ let duration = sample.endDate.timeIntervalSince(sample.startDate)
732
+ dailyDurations[startOfDay, default: 0] += duration
733
+ }
734
+ var aggregatedSamples: [[String: Any]] = []
735
+ let dayComponent = DateComponents(day: 1)
736
+ for (date, duration) in dailyDurations {
737
+ aggregatedSamples.append([
738
+ "startDate": date,
739
+ "endDate": calendar.date(byAdding: dayComponent, to: date) as Any,
740
+ "value": duration
741
+ ])
742
+ }
743
+ DispatchQueue.main.async {
744
+ completion(aggregatedSamples, nil)
745
+ }
746
+ } else {
747
+ DispatchQueue.main.async {
748
+ completion(nil, error)
749
+ }
750
+ }
751
+ }
752
+ healthStore.execute(query)
753
+ }
754
+
755
+ func queryBloodPressureAggregated(startDate: Date, endDate: Date, completion: @escaping ([[String: Any]]?, Error?) -> Void) {
756
+ guard let bpType = HKObjectType.correlationType(forIdentifier: .bloodPressure),
757
+ let systolicType = HKObjectType.quantityType(forIdentifier: .bloodPressureSystolic),
758
+ let diastolicType = HKObjectType.quantityType(forIdentifier: .bloodPressureDiastolic) else {
759
+ DispatchQueue.main.async {
760
+ completion(nil, NSError(domain: "HealthKit", code: -1, userInfo: [NSLocalizedDescriptionKey: "Blood pressure types unavailable"]))
761
+ }
762
+ return
763
+ }
764
+
765
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
766
+ let query = HKSampleQuery(sampleType: bpType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
767
+ if let error = error {
768
+ DispatchQueue.main.async {
769
+ completion(nil, error)
770
+ }
771
+ return
772
+ }
773
+
774
+ guard let correlations = samples as? [HKCorrelation] else {
775
+ DispatchQueue.main.async {
776
+ completion([], nil)
777
+ }
778
+ return
779
+ }
780
+
781
+ let calendar = Calendar.current
782
+ let dayComponent = DateComponents(day: 1)
783
+ let unit = HKUnit.millimeterOfMercury()
784
+
785
+ var grouped: [Date: [(Double, Double)]] = [:]
786
+ for correlation in correlations {
787
+ guard let systolicSample = correlation.objects(for: systolicType).first as? HKQuantitySample,
788
+ let diastolicSample = correlation.objects(for: diastolicType).first as? HKQuantitySample else { continue }
789
+ let dayStart = calendar.startOfDay(for: correlation.startDate)
790
+ let systolicValue = systolicSample.quantity.doubleValue(for: unit)
791
+ let diastolicValue = diastolicSample.quantity.doubleValue(for: unit)
792
+ grouped[dayStart, default: []].append((systolicValue, diastolicValue))
793
+ }
794
+
795
+ var aggregatedSamples: [[String: Any]] = []
796
+ for (dayStart, readings) in grouped {
797
+ guard !readings.isEmpty else { continue }
798
+ let systolicAvg = readings.map { $0.0 }.reduce(0, +) / Double(readings.count)
799
+ let diastolicAvg = readings.map { $0.1 }.reduce(0, +) / Double(readings.count)
800
+ let bucketStart = dayStart.timeIntervalSince1970 * 1000
801
+ let bucketEnd = (calendar.date(byAdding: dayComponent, to: dayStart)?.timeIntervalSince1970 ?? dayStart.timeIntervalSince1970) * 1000
802
+ aggregatedSamples.append([
803
+ "startDate": bucketStart,
804
+ "endDate": bucketEnd,
805
+ "systolic": systolicAvg,
806
+ "diastolic": diastolicAvg,
807
+ "value": systolicAvg,
808
+ "unit": unit.unitString
809
+ ])
810
+ }
811
+
812
+ aggregatedSamples.sort { (lhs, rhs) in
813
+ guard let lhsStart = lhs["startDate"] as? Double, let rhsStart = rhs["startDate"] as? Double else { return false }
814
+ return lhsStart < rhsStart
815
+ }
816
+
817
+ DispatchQueue.main.async {
818
+ completion(aggregatedSamples, nil)
819
+ }
820
+ }
821
+ healthStore.execute(query)
822
+ }
823
+
824
+ private func queryLatestTotalCalories(_ call: CAPPluginCall) {
825
+ let unit = HKUnit.kilocalorie()
826
+ let group = DispatchGroup()
827
+
828
+ var activeValue: Double?
829
+ var basalValue: Double?
830
+ var activeDate: Date?
831
+ var basalDate: Date?
832
+ var queryError: Error?
833
+ let basalSupported = HKObjectType.quantityType(forIdentifier: .basalEnergyBurned) != nil
834
+
835
+ func fetchLatest(_ identifier: HKQuantityTypeIdentifier, completion: @escaping (Double?, Date?, Error?) -> Void) {
836
+ guard let type = HKObjectType.quantityType(forIdentifier: identifier) else {
837
+ completion(nil, nil, NSError(domain: "HealthKit", code: -1, userInfo: [NSLocalizedDescriptionKey: "Quantity type unavailable"]))
838
+ return
839
+ }
840
+ let predicate = HKQuery.predicateForSamples(withStart: Date.distantPast, end: Date(), options: .strictEndDate)
841
+ let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
842
+ let query = HKSampleQuery(sampleType: type, predicate: predicate, limit: 1, sortDescriptors: [sort]) { _, samples, error in
843
+ if let error = error {
844
+ completion(nil, nil, error)
845
+ return
846
+ }
847
+ guard let sample = samples?.first as? HKQuantitySample else {
848
+ completion(nil, nil, nil)
849
+ return
850
+ }
851
+ completion(sample.quantity.doubleValue(for: unit), sample.startDate, nil)
852
+ }
853
+ healthStore.execute(query)
854
+ }
855
+
856
+ group.enter()
857
+ fetchLatest(.activeEnergyBurned) { value, date, error in
858
+ activeValue = value
859
+ activeDate = date
860
+ queryError = queryError ?? error
861
+ group.leave()
862
+ }
863
+
864
+ if basalSupported {
865
+ group.enter()
866
+ fetchLatest(.basalEnergyBurned) { value, date, error in
867
+ basalValue = value
868
+ basalDate = date
869
+ queryError = queryError ?? error
870
+ group.leave()
871
+ }
872
+ }
873
+
874
+ group.notify(queue: .main) {
875
+ if let error = queryError {
876
+ call.reject("Error fetching total calories: \(error.localizedDescription)")
877
+ return
878
+ }
879
+ guard activeValue != nil || basalValue != nil else {
880
+ call.reject("No sample found", "NO_SAMPLE")
881
+ return
882
+ }
883
+ let total = (activeValue ?? 0) + (basalValue ?? 0)
884
+ let timestamp = max(activeDate?.timeIntervalSince1970 ?? 0, basalDate?.timeIntervalSince1970 ?? 0) * 1000
885
+ call.resolve([
886
+ "value": total,
887
+ "timestamp": timestamp,
888
+ "unit": unit.unitString
889
+ ])
890
+ }
891
+ }
892
+
893
+ private func collectEnergyBuckets(
894
+ _ identifier: HKQuantityTypeIdentifier,
895
+ startDate: Date,
896
+ endDate: Date,
897
+ interval: DateComponents,
898
+ completion: @escaping ([TimeInterval: (start: TimeInterval, end: TimeInterval, value: Double)]?, Error?) -> Void
899
+ ) {
900
+ guard let type = HKObjectType.quantityType(forIdentifier: identifier) else {
901
+ completion([:], nil)
902
+ return
903
+ }
904
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
905
+ let query = HKStatisticsCollectionQuery(
906
+ quantityType: type,
907
+ quantitySamplePredicate: predicate,
908
+ options: .cumulativeSum,
909
+ anchorDate: startDate,
910
+ intervalComponents: interval
911
+ )
912
+ query.initialResultsHandler = { _, result, error in
913
+ if let error = error {
914
+ completion(nil, error)
915
+ return
916
+ }
917
+ var buckets: [TimeInterval: (start: TimeInterval, end: TimeInterval, value: Double)] = [:]
918
+ result?.enumerateStatistics(from: startDate, to: endDate) { statistics, _ in
919
+ guard let quantity = statistics.sumQuantity() else { return }
920
+ let startMs = statistics.startDate.timeIntervalSince1970 * 1000
921
+ let endMs = statistics.endDate.timeIntervalSince1970 * 1000
922
+ buckets[startMs] = (startMs, endMs, quantity.doubleValue(for: HKUnit.kilocalorie()))
923
+ }
924
+ completion(buckets, nil)
925
+ }
926
+ healthStore.execute(query)
927
+ }
928
+
929
+ private func queryTotalCaloriesAggregated(
930
+ startDate: Date,
931
+ endDate: Date,
932
+ interval: DateComponents,
933
+ completion: @escaping ([[String: Any]]?, Error?) -> Void
934
+ ) {
935
+ let group = DispatchGroup()
936
+ let basalSupported = HKObjectType.quantityType(forIdentifier: .basalEnergyBurned) != nil
937
+ var activeBuckets: [TimeInterval: (start: TimeInterval, end: TimeInterval, value: Double)]?
938
+ var basalBuckets: [TimeInterval: (start: TimeInterval, end: TimeInterval, value: Double)]?
939
+ var queryError: Error?
940
+
941
+ group.enter()
942
+ collectEnergyBuckets(.activeEnergyBurned, startDate: startDate, endDate: endDate, interval: interval) { buckets, error in
943
+ activeBuckets = buckets
944
+ queryError = queryError ?? error
945
+ group.leave()
946
+ }
947
+
948
+ if basalSupported {
949
+ group.enter()
950
+ collectEnergyBuckets(.basalEnergyBurned, startDate: startDate, endDate: endDate, interval: interval) { buckets, error in
951
+ basalBuckets = buckets
952
+ queryError = queryError ?? error
953
+ group.leave()
954
+ }
955
+ }
956
+
957
+ group.notify(queue: .main) {
958
+ if let error = queryError {
959
+ completion(nil, error)
960
+ return
961
+ }
962
+ let active = activeBuckets ?? [:]
963
+ let basal = basalBuckets ?? [:]
964
+ let allKeys = Set(active.keys).union(basal.keys).sorted()
965
+ var aggregated: [[String: Any]] = []
966
+ let calendar = Calendar.current
967
+ for key in allKeys {
968
+ let startMs = key
969
+ let endMs = active[key]?.end ?? basal[key]?.end ?? {
970
+ let date = Date(timeIntervalSince1970: startMs / 1000)
971
+ return (calendar.date(byAdding: interval, to: date) ?? date).timeIntervalSince1970 * 1000
972
+ }()
973
+ let value = (active[key]?.value ?? 0) + (basal[key]?.value ?? 0)
974
+ aggregated.append([
975
+ "startDate": startMs,
976
+ "endDate": endMs,
977
+ "value": value
978
+ ])
979
+ }
980
+ completion(aggregated.sorted { ($0["startDate"] as? Double ?? 0) < ($1["startDate"] as? Double ?? 0) }, nil)
981
+ }
982
+ }
535
983
 
536
984
  private func queryAggregated(for startDate: Date, for endDate: Date, for dataType: HKQuantityType?, completion: @escaping (Double?) -> Void) {
537
985
  guard let quantityType = dataType else {
@@ -609,7 +1057,7 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
609
1057
  return
610
1058
  }
611
1059
  let outerGroup = DispatchGroup()
612
- let resultsQueue = DispatchQueue(label: "com.flomentum.healthplugin.workoutResults")
1060
+ let resultsQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.workoutResults")
613
1061
  var workoutResults: [[String: Any]] = []
614
1062
  var errors: [String: String] = [:]
615
1063
 
@@ -627,48 +1075,54 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
627
1075
  "distance": workout.totalDistance?.doubleValue(for: .meter()) ?? 0
628
1076
  ]
629
1077
  let innerGroup = DispatchGroup()
1078
+ let heartRateQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.heartRates")
1079
+ let routeQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.routes")
630
1080
  var localHeartRates: [[String: Any]] = []
631
1081
  var localRoutes: [[String: Any]] = []
632
1082
 
633
1083
  if includeHeartRate {
634
1084
  innerGroup.enter()
635
1085
  self.queryHeartRate(for: workout) { rates, error in
636
- localHeartRates = rates
637
- if let error = error {
638
- resultsQueue.async {
639
- errors["heart-rate"] = error
1086
+ heartRateQueue.async {
1087
+ localHeartRates = rates
1088
+ if let error = error {
1089
+ errors["heart-rate"] = error
640
1090
  }
1091
+ innerGroup.leave()
641
1092
  }
642
- innerGroup.leave()
643
1093
  }
644
1094
  }
645
1095
  if includeRoute {
646
1096
  innerGroup.enter()
647
1097
  self.queryRoute(for: workout) { routes, error in
648
- localRoutes = routes
649
- if let error = error {
650
- resultsQueue.async {
651
- errors["route"] = error
1098
+ routeQueue.async {
1099
+ localRoutes = routes
1100
+ if let error = error {
1101
+ errors["route"] = error
652
1102
  }
1103
+ innerGroup.leave()
653
1104
  }
654
- innerGroup.leave()
655
1105
  }
656
1106
  }
657
1107
  if includeSteps {
658
1108
  innerGroup.enter()
659
1109
  self.queryAggregated(for: workout.startDate, for: workout.endDate, for: HKObjectType.quantityType(forIdentifier: .stepCount)) { steps in
660
- if let steps = steps {
661
- localDict["steps"] = steps
1110
+ resultsQueue.async {
1111
+ if let steps = steps {
1112
+ localDict["steps"] = steps
1113
+ }
1114
+ innerGroup.leave()
662
1115
  }
663
- innerGroup.leave()
664
1116
  }
665
1117
  }
666
- innerGroup.notify(queue: .main) {
667
- localDict["heartRate"] = localHeartRates
668
- localDict["route"] = localRoutes
669
- resultsQueue.async {
670
- workoutResults.append(localDict)
1118
+ innerGroup.notify(queue: resultsQueue) {
1119
+ heartRateQueue.sync {
1120
+ localDict["heartRate"] = localHeartRates
1121
+ }
1122
+ routeQueue.sync {
1123
+ localDict["route"] = localRoutes
671
1124
  }
1125
+ workoutResults.append(localDict)
672
1126
  outerGroup.leave()
673
1127
  }
674
1128
  }
@@ -721,7 +1175,7 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
721
1175
  guard let self = self else { return }
722
1176
  if let routes = samples as? [HKWorkoutRoute], error == nil {
723
1177
  let routeDispatchGroup = DispatchGroup()
724
- let allLocationsQueue = DispatchQueue(label: "com.flomentum.healthplugin.allLocations")
1178
+ let allLocationsQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.allLocations")
725
1179
  var allLocations: [[String: Any]] = []
726
1180
 
727
1181
  for route in routes {
@@ -870,4 +1324,4 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
870
1324
  3000 : "other"
871
1325
  ]
872
1326
 
873
- }
1327
+ }