@flomentumsolutions/capacitor-health-extended 0.3.1 → 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.
- package/FlomentumSolutionsCapacitorHealthExtended.podspec +1 -1
- package/Package.swift +2 -2
- package/README.md +86 -14
- package/android/src/main/java/com/flomentum/health/capacitor/HealthPlugin.kt +327 -182
- package/android/src/main/java/com/flomentum/health/capacitor/PermissionsRationaleActivity.kt +1 -1
- package/dist/esm/definitions.d.ts +3 -0
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/HealthPluginPlugin/HealthPlugin.swift +287 -69
- package/package.json +7 -2
|
@@ -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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
580
|
-
let
|
|
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
|
+
"version": "0.4.1",
|
|
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/",
|
|
@@ -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",
|