@flomentumsolutions/capacitor-health-extended 0.3.1 → 0.4.2

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.
@@ -25,7 +25,7 @@
25
25
  *
26
26
  */
27
27
 
28
- // package com.[YOUR_ORGANIZATION].[YOUR_APP...]
28
+ // package com.my.app.package.name // <-- CHANGE THIS TO YOUR APP'S PACKAGE NAME
29
29
 
30
30
  import android.os.Bundle
31
31
  import android.util.Log
@@ -129,6 +129,9 @@ export interface AggregatedSample {
129
129
  startDate: string;
130
130
  endDate: string;
131
131
  value: number;
132
+ systolic?: number;
133
+ diastolic?: number;
134
+ unit?: string;
132
135
  }
133
136
  export interface QueryLatestSampleResponse {
134
137
  value?: number;
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export interface HealthPlugin {\n /**\n * Checks if health API is available.\n * Android: If false is returned, the Google Health Connect app is probably not installed.\n * See showHealthConnectInPlayStore()\n *\n */\n isHealthAvailable(): Promise<{ available: boolean }>;\n\n /**\n * Android only: Returns for each given permission, if it was granted by the underlying health API\n * @param permissions permissions to query\n */\n checkHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Requests the permissions from the user.\n *\n * Android: Apps can ask only a few times for permissions, after that the user has to grant them manually in\n * the Health Connect app. See openHealthConnectSettings()\n *\n * iOS: If the permissions are already granted or denied, this method will just return without asking the user. In iOS\n * we can't really detect if a user granted or denied a permission. The return value reflects the assumption that all\n * permissions were granted.\n *\n * @param permissions permissions to request\n */\n requestHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Opens the apps settings, which is kind of wrong, because health permissions are configured under:\n * Settings > Apps > (Apple) Health > Access and Devices > [app-name]\n * But we can't go there directly.\n */\n openAppleHealthSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app\n */\n openHealthConnectSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app in PlayStore\n */\n showHealthConnectInPlayStore(): Promise<void>;\n\n /**\n * Query aggregated data\n * @param request\n */\n queryAggregated(request: QueryAggregatedRequest): Promise<QueryAggregatedResponse>;\n\n /**\n * Query workouts\n * @param request\n */\n queryWorkouts(request: QueryWorkoutRequest): Promise<QueryWorkoutResponse>;\n\n /**\n * Query latest sample for a specific data type\n * @param request\n */\n queryLatestSample(request: { dataType: LatestDataType }): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest weight sample\n */\n queryWeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest height sample\n */\n queryHeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest heart rate sample\n */\n queryHeartRate(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest steps sample\n */\n querySteps(): Promise<QueryLatestSampleResponse>;\n}\n\nexport declare type HealthPermission =\n | 'READ_STEPS'\n | 'READ_WORKOUTS'\n | 'READ_ACTIVE_CALORIES'\n | 'READ_TOTAL_CALORIES'\n | 'READ_DISTANCE'\n | 'READ_WEIGHT'\n | 'READ_HEIGHT'\n | 'READ_HEART_RATE'\n | 'READ_RESTING_HEART_RATE'\n | 'READ_ROUTE'\n | 'READ_MINDFULNESS'\n | 'READ_HRV'\n | 'READ_BLOOD_PRESSURE'\n | 'READ_BASAL_CALORIES'\n | 'READ_RESPIRATORY_RATE'\n | 'READ_OXYGEN_SATURATION'\n | 'READ_BLOOD_GLUCOSE'\n | 'READ_BODY_TEMPERATURE'\n | 'READ_BASAL_BODY_TEMPERATURE'\n | 'READ_BODY_FAT'\n | 'READ_FLOORS_CLIMBED'\n | 'READ_SLEEP'\n | 'READ_EXERCISE_TIME';\n\nexport type LatestDataType =\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'mindfulness'\n | 'sleep'\n | 'hrv'\n | 'blood-pressure';\n\nexport interface PermissionsRequest {\n permissions: HealthPermission[];\n}\n\nexport interface PermissionResponse {\n permissions: Record<HealthPermission, boolean>;\n}\n\nexport interface QueryWorkoutRequest {\n startDate: string;\n endDate: string;\n includeHeartRate: boolean;\n includeRoute: boolean;\n includeSteps: boolean;\n}\n\nexport interface HeartRateSample {\n timestamp: string;\n bpm: number;\n}\n\nexport interface RouteSample {\n timestamp: string;\n lat: number;\n lng: number;\n alt?: number;\n}\n\nexport interface QueryWorkoutResponse {\n workouts: Workout[];\n}\n\nexport interface Workout {\n startDate: string;\n endDate: string;\n workoutType: string;\n sourceName: string;\n id?: string;\n duration: number;\n distance?: number;\n steps?: number;\n calories: number;\n sourceBundleId: string;\n route?: RouteSample[];\n heartRate?: HeartRateSample[];\n}\n\nexport interface QueryAggregatedRequest {\n startDate: string;\n endDate: string;\n dataType:\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'sleep'\n | 'mindfulness'\n | 'hrv'\n | 'blood-pressure';\n bucket: string;\n}\n\nexport interface QueryAggregatedResponse {\n aggregatedData: AggregatedSample[];\n}\n\nexport interface AggregatedSample {\n startDate: string;\n endDate: string;\n value: number;\n}\n\nexport interface QueryLatestSampleResponse {\n value?: number;\n systolic?: number;\n diastolic?: number;\n timestamp: number;\n endTimestamp?: number;\n unit: string;\n metadata?: Record<string, unknown>;\n}\n"]}
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export interface HealthPlugin {\n /**\n * Checks if health API is available.\n * Android: If false is returned, the Google Health Connect app is probably not installed.\n * See showHealthConnectInPlayStore()\n *\n */\n isHealthAvailable(): Promise<{ available: boolean }>;\n\n /**\n * Android only: Returns for each given permission, if it was granted by the underlying health API\n * @param permissions permissions to query\n */\n checkHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Requests the permissions from the user.\n *\n * Android: Apps can ask only a few times for permissions, after that the user has to grant them manually in\n * the Health Connect app. See openHealthConnectSettings()\n *\n * iOS: If the permissions are already granted or denied, this method will just return without asking the user. In iOS\n * we can't really detect if a user granted or denied a permission. The return value reflects the assumption that all\n * permissions were granted.\n *\n * @param permissions permissions to request\n */\n requestHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Opens the apps settings, which is kind of wrong, because health permissions are configured under:\n * Settings > Apps > (Apple) Health > Access and Devices > [app-name]\n * But we can't go there directly.\n */\n openAppleHealthSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app\n */\n openHealthConnectSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app in PlayStore\n */\n showHealthConnectInPlayStore(): Promise<void>;\n\n /**\n * Query aggregated data\n * @param request\n */\n queryAggregated(request: QueryAggregatedRequest): Promise<QueryAggregatedResponse>;\n\n /**\n * Query workouts\n * @param request\n */\n queryWorkouts(request: QueryWorkoutRequest): Promise<QueryWorkoutResponse>;\n\n /**\n * Query latest sample for a specific data type\n * @param request\n */\n queryLatestSample(request: { dataType: LatestDataType }): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest weight sample\n */\n queryWeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest height sample\n */\n queryHeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest heart rate sample\n */\n queryHeartRate(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest steps sample\n */\n querySteps(): Promise<QueryLatestSampleResponse>;\n}\n\nexport declare type HealthPermission =\n | 'READ_STEPS'\n | 'READ_WORKOUTS'\n | 'READ_ACTIVE_CALORIES'\n | 'READ_TOTAL_CALORIES'\n | 'READ_DISTANCE'\n | 'READ_WEIGHT'\n | 'READ_HEIGHT'\n | 'READ_HEART_RATE'\n | 'READ_RESTING_HEART_RATE'\n | 'READ_ROUTE'\n | 'READ_MINDFULNESS'\n | 'READ_HRV'\n | 'READ_BLOOD_PRESSURE'\n | 'READ_BASAL_CALORIES'\n | 'READ_RESPIRATORY_RATE'\n | 'READ_OXYGEN_SATURATION'\n | 'READ_BLOOD_GLUCOSE'\n | 'READ_BODY_TEMPERATURE'\n | 'READ_BASAL_BODY_TEMPERATURE'\n | 'READ_BODY_FAT'\n | 'READ_FLOORS_CLIMBED'\n | 'READ_SLEEP'\n | 'READ_EXERCISE_TIME';\n\nexport type LatestDataType =\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'mindfulness'\n | 'sleep'\n | 'hrv'\n | 'blood-pressure';\n\nexport interface PermissionsRequest {\n permissions: HealthPermission[];\n}\n\nexport interface PermissionResponse {\n permissions: Record<HealthPermission, boolean>;\n}\n\nexport interface QueryWorkoutRequest {\n startDate: string;\n endDate: string;\n includeHeartRate: boolean;\n includeRoute: boolean;\n includeSteps: boolean;\n}\n\nexport interface HeartRateSample {\n timestamp: string;\n bpm: number;\n}\n\nexport interface RouteSample {\n timestamp: string;\n lat: number;\n lng: number;\n alt?: number;\n}\n\nexport interface QueryWorkoutResponse {\n workouts: Workout[];\n}\n\nexport interface Workout {\n startDate: string;\n endDate: string;\n workoutType: string;\n sourceName: string;\n id?: string;\n duration: number;\n distance?: number;\n steps?: number;\n calories: number;\n sourceBundleId: string;\n route?: RouteSample[];\n heartRate?: HeartRateSample[];\n}\n\nexport interface QueryAggregatedRequest {\n startDate: string;\n endDate: string;\n dataType:\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'sleep'\n | 'mindfulness'\n | 'hrv'\n | 'blood-pressure';\n bucket: string;\n}\n\nexport interface QueryAggregatedResponse {\n aggregatedData: AggregatedSample[];\n}\n\nexport interface AggregatedSample {\n startDate: string;\n endDate: string;\n value: number;\n systolic?: number;\n diastolic?: number;\n unit?: string;\n}\n\nexport interface QueryLatestSampleResponse {\n value?: number;\n systolic?: number;\n diastolic?: number;\n timestamp: number;\n endTimestamp?: number;\n unit: string;\n metadata?: Record<string, unknown>;\n}\n"]}
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import UIKit
2
3
  import Capacitor
3
4
  import HealthKit
4
5
 
@@ -17,11 +18,7 @@ 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()
@@ -153,6 +150,11 @@ 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
+ }
156
158
  // ---- Special handling for sleep sessions (category samples) ----
157
159
  if dataTypeString == "sleep" {
158
160
  guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
@@ -238,17 +240,15 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
238
240
  return HKObjectType.quantityType(forIdentifier: .height)
239
241
  case "distance":
240
242
  return HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning)
241
- case "distance-cycling":
242
- return HKObjectType.quantityType(forIdentifier: .distanceCycling)
243
- case "active-calories":
244
- return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
245
- case "total-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":
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
252
  return HKObjectType.quantityType(forIdentifier: .oxygenSaturation)
253
253
  case "blood-glucose":
254
254
  return HKObjectType.quantityType(forIdentifier: .bloodGlucose)
@@ -339,57 +339,6 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
339
339
  healthStore.execute(query)
340
340
  }
341
341
 
342
- // Convenience methods for specific data types
343
- @objc func queryWeight(_ call: CAPPluginCall) {
344
- queryLatestSampleWithType(call, dataType: "weight")
345
- }
346
-
347
- @objc func queryHeight(_ call: CAPPluginCall) {
348
- queryLatestSampleWithType(call, dataType: "height")
349
- }
350
-
351
- @objc func queryHeartRate(_ call: CAPPluginCall) {
352
- queryLatestSampleWithType(call, dataType: "heart-rate")
353
- }
354
-
355
- @objc func querySteps(_ call: CAPPluginCall) {
356
- queryLatestSampleWithType(call, dataType: "steps")
357
- }
358
-
359
- private func queryLatestSampleWithType(_ call: CAPPluginCall, dataType: String) {
360
- // Safely coerce the original options into a [String: Any] JSObject.
361
- let originalOptions = call.options as? [String: Any] ?? [:]
362
- var params = originalOptions
363
- params["dataType"] = dataType
364
-
365
- // Create a proxy CAPPluginCall using the CURRENT (Capacitor 6) designated initializer.
366
- // NOTE: The older init(callbackId:options:success:error:) is deprecated and *failable*,
367
- // so we use the newer initializer that requires a method name. Guard against failure.
368
- guard let proxyCall = CAPPluginCall(
369
- callbackId: call.callbackId,
370
- methodName: "queryLatestSample", // required in new API
371
- options: params,
372
- success: { result, _ in
373
- // Forward the resolved data back to the original JS caller.
374
- call.resolve(result?.data ?? [:])
375
- },
376
- error: { capError in
377
- // Forward the error to the original call in the legacy reject format.
378
- if let capError = capError {
379
- call.reject(capError.message, capError.code, capError.error, capError.data)
380
- } else {
381
- call.reject("Unknown native error")
382
- }
383
- }
384
- ) else {
385
- call.reject("Failed to create proxy call")
386
- return
387
- }
388
-
389
- // Delegate the actual HealthKit fetch to the common implementation.
390
- queryLatestSample(proxyCall)
391
- }
392
-
393
342
  @objc func openAppleHealthSettings(_ call: CAPPluginCall) {
394
343
  if let url = URL(string: UIApplication.openSettingsURLString) {
395
344
  DispatchQueue.main.async {
@@ -576,13 +525,21 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
576
525
  let endDateString = call.getString("endDate"),
577
526
  let dataTypeString = call.getString("dataType"),
578
527
  let bucket = call.getString("bucket"),
579
- let startDate = self.isoDateFormatter.date(from: startDateString),
580
- 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 {
581
530
  DispatchQueue.main.async {
582
531
  call.reject("Invalid parameters")
583
532
  }
584
533
  return
585
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
+ }
586
543
  if dataTypeString == "mindfulness" {
587
544
  self.queryMindfulnessAggregated(startDate: startDate, endDate: endDate) { result, error in
588
545
  DispatchQueue.main.async {
@@ -593,6 +550,38 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
593
550
  }
594
551
  }
595
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
+ }
596
585
  } else if dataTypeString == "sleep" {
597
586
  self.querySleepAggregated(startDate: startDate, endDate: endDate) { result, error in
598
587
  DispatchQueue.main.async {
@@ -762,6 +751,235 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
762
751
  }
763
752
  healthStore.execute(query)
764
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
+ }
765
983
 
766
984
  private func queryAggregated(for startDate: Date, for endDate: Date, for dataType: HKQuantityType?, completion: @escaping (Double?) -> Void) {
767
985
  guard let quantityType = dataType else {
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "@flomentumsolutions/capacitor-health-extended",
3
- "version": "0.3.1",
3
+ "version": "0.4.2",
4
4
  "description": "Capacitor plugin for Apple HealthKit and Google Health Connect Platform",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",
7
7
  "types": "dist/esm/index.d.ts",
8
+ "private": false,
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
8
12
  "unpkg": "dist/plugin.js",
9
13
  "files": [
10
14
  "android/src/main/",
@@ -13,7 +17,7 @@
13
17
  "ios/Sources",
14
18
  "ios/Tests",
15
19
  "Package.swift",
16
- "FlomentumSolutionsCapacitorHealthExtended.podspec"
20
+ "FlomentumsolutionsCapacitorHealthExtended.podspec"
17
21
  ],
18
22
  "author": {
19
23
  "name": "Flomentum Solutions, LLC",
@@ -36,7 +40,7 @@
36
40
  ],
37
41
  "repository": {
38
42
  "type": "git",
39
- "url": "git+https://github.com/Flomentum-Solutions/capacitor-health-extended.git"
43
+ "url": "https://github.com/Flomentum-Solutions/capacitor-health-extended.git"
40
44
  },
41
45
  "bugs": {
42
46
  "url": "https://github.com/Flomentum-Solutions/capacitor-health-extended/issues"
@@ -65,7 +69,8 @@
65
69
  "build": "npm run clean && tsc && rollup -c rollup.config.mjs",
66
70
  "clean": "rimraf ./dist",
67
71
  "watch": "tsc --watch",
68
- "prepublishOnly": "npm run build"
72
+ "prepublishOnly": "npm run build",
73
+ "postversion": "git push --follow-tags"
69
74
  },
70
75
  "devDependencies": {
71
76
  "@capacitor/android": "^8.0.0",