@flomentumsolutions/capacitor-health-extended 0.1.0 → 0.3.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.
@@ -55,7 +55,7 @@ export interface HealthPlugin {
55
55
  * @param request
56
56
  */
57
57
  queryLatestSample(request: {
58
- dataType: string;
58
+ dataType: LatestDataType;
59
59
  }): Promise<QueryLatestSampleResponse>;
60
60
  /**
61
61
  * Query latest weight sample
@@ -74,14 +74,13 @@ export interface HealthPlugin {
74
74
  */
75
75
  querySteps(): Promise<QueryLatestSampleResponse>;
76
76
  }
77
- export declare type HealthPermission = 'READ_STEPS' | 'READ_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'READ_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'READ_ROUTE' | 'READ_MINDFULNESS' | 'READ_HRV' | 'READ_BLOOD_PRESSURE';
77
+ export declare type HealthPermission = 'READ_STEPS' | 'READ_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'READ_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'READ_RESTING_HEART_RATE' | 'READ_ROUTE' | 'READ_MINDFULNESS' | 'READ_HRV' | 'READ_BLOOD_PRESSURE' | 'READ_BASAL_CALORIES' | 'READ_RESPIRATORY_RATE' | 'READ_OXYGEN_SATURATION' | 'READ_BLOOD_GLUCOSE' | 'READ_BODY_TEMPERATURE' | 'READ_BASAL_BODY_TEMPERATURE' | 'READ_BODY_FAT' | 'READ_FLOORS_CLIMBED' | 'READ_SLEEP' | 'READ_EXERCISE_TIME';
78
+ export type LatestDataType = 'steps' | 'active-calories' | 'total-calories' | 'basal-calories' | 'distance' | 'weight' | 'height' | 'heart-rate' | 'resting-heart-rate' | 'respiratory-rate' | 'oxygen-saturation' | 'blood-glucose' | 'body-temperature' | 'basal-body-temperature' | 'body-fat' | 'flights-climbed' | 'exercise-time' | 'distance-cycling' | 'mindfulness' | 'sleep' | 'hrv' | 'blood-pressure';
78
79
  export interface PermissionsRequest {
79
80
  permissions: HealthPermission[];
80
81
  }
81
82
  export interface PermissionResponse {
82
- permissions: {
83
- [key: string]: boolean;
84
- }[];
83
+ permissions: Record<HealthPermission, boolean>;
85
84
  }
86
85
  export interface QueryWorkoutRequest {
87
86
  startDate: string;
@@ -120,7 +119,7 @@ export interface Workout {
120
119
  export interface QueryAggregatedRequest {
121
120
  startDate: string;
122
121
  endDate: string;
123
- dataType: 'steps' | 'active-calories' | 'mindfulness' | 'hrv' | 'blood-pressure';
122
+ dataType: 'steps' | 'active-calories' | 'total-calories' | 'basal-calories' | 'distance' | 'weight' | 'height' | 'heart-rate' | 'resting-heart-rate' | 'respiratory-rate' | 'oxygen-saturation' | 'blood-glucose' | 'body-temperature' | 'basal-body-temperature' | 'body-fat' | 'flights-climbed' | 'exercise-time' | 'distance-cycling' | 'sleep' | 'mindfulness' | 'hrv' | 'blood-pressure';
124
123
  bucket: string;
125
124
  }
126
125
  export interface QueryAggregatedResponse {
@@ -136,5 +135,7 @@ export interface QueryLatestSampleResponse {
136
135
  systolic?: number;
137
136
  diastolic?: number;
138
137
  timestamp: number;
138
+ endTimestamp?: number;
139
139
  unit: string;
140
+ metadata?: Record<string, unknown>;
140
141
  }
@@ -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: string }): 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_ROUTE'\n | 'READ_MINDFULNESS'\n | 'READ_HRV'\n | 'READ_BLOOD_PRESSURE';\n\nexport interface PermissionsRequest {\n permissions: HealthPermission[];\n}\n\nexport interface PermissionResponse {\n permissions: { [key: string]: 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: 'steps' | 'active-calories' | 'mindfulness' | 'hrv' | '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 unit: string;\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}\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"]}
@@ -27,7 +27,7 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
27
27
  let healthStore = HKHealthStore()
28
28
 
29
29
  /// Serial queue to make route‑location mutations thread‑safe without locks
30
- private let routeSyncQueue = DispatchQueue(label: "com.flomentum.healthplugin.routeSync")
30
+ private let routeSyncQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.routeSync")
31
31
 
32
32
  @objc func isHealthAvailable(_ call: CAPPluginCall) {
33
33
  let isAvailable = HKHealthStore.isHealthDataAvailable()
@@ -153,6 +153,68 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
153
153
  healthStore.execute(query)
154
154
  return
155
155
  }
156
+ // ---- Special handling for sleep sessions (category samples) ----
157
+ if dataTypeString == "sleep" {
158
+ guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
159
+ call.reject("Sleep type not available")
160
+ return
161
+ }
162
+
163
+ let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
164
+ let predicate = HKQuery.predicateForSamples(withStart: Date.distantPast, end: Date(), options: .strictEndDate)
165
+
166
+ let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: 1, sortDescriptors: [sortDescriptor]) { _, samples, error in
167
+ guard let sleepSample = samples?.first as? HKCategorySample else {
168
+ if let error = error {
169
+ call.reject("Error fetching latest sleep sample", "NO_SAMPLE", error)
170
+ } else {
171
+ call.reject("No sleep sample found", "NO_SAMPLE")
172
+ }
173
+ return
174
+ }
175
+ let durationMinutes = sleepSample.endDate.timeIntervalSince(sleepSample.startDate) / 60
176
+ call.resolve([
177
+ "value": durationMinutes,
178
+ "timestamp": sleepSample.startDate.timeIntervalSince1970 * 1000,
179
+ "endTimestamp": sleepSample.endDate.timeIntervalSince1970 * 1000,
180
+ "unit": "min",
181
+ "metadata": ["state": sleepSample.value]
182
+ ])
183
+ }
184
+ healthStore.execute(query)
185
+ return
186
+ }
187
+ // ---- Special handling for mindfulness sessions (category samples) ----
188
+ if dataTypeString == "mindfulness" {
189
+ guard let mindfulType = HKObjectType.categoryType(forIdentifier: .mindfulSession) else {
190
+ call.reject("Mindfulness type not available")
191
+ return
192
+ }
193
+
194
+ let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
195
+ let predicate = HKQuery.predicateForSamples(withStart: Date.distantPast, end: Date(), options: .strictEndDate)
196
+
197
+ let query = HKSampleQuery(sampleType: mindfulType, predicate: predicate, limit: 1, sortDescriptors: [sortDescriptor]) { _, samples, error in
198
+ guard let mindfulSample = samples?.first as? HKCategorySample else {
199
+ if let error = error {
200
+ call.reject("Error fetching latest mindfulness sample", "NO_SAMPLE", error)
201
+ } else {
202
+ call.reject("No mindfulness sample found", "NO_SAMPLE")
203
+ }
204
+ return
205
+ }
206
+ let durationMinutes = mindfulSample.endDate.timeIntervalSince(mindfulSample.startDate) / 60
207
+ call.resolve([
208
+ "value": durationMinutes,
209
+ "timestamp": mindfulSample.startDate.timeIntervalSince1970 * 1000,
210
+ "endTimestamp": mindfulSample.endDate.timeIntervalSince1970 * 1000,
211
+ "unit": "min",
212
+ "metadata": ["value": mindfulSample.value]
213
+ ])
214
+ }
215
+ healthStore.execute(query)
216
+ return
217
+ }
156
218
  guard aggregateTypeToHKQuantityType(dataTypeString) != nil else {
157
219
  call.reject("Invalid data type")
158
220
  return
@@ -162,6 +224,10 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
162
224
  switch dataTypeString {
163
225
  case "heart-rate":
164
226
  return HKObjectType.quantityType(forIdentifier: .heartRate)
227
+ case "resting-heart-rate":
228
+ return HKObjectType.quantityType(forIdentifier: .restingHeartRate)
229
+ case "respiratory-rate":
230
+ return HKObjectType.quantityType(forIdentifier: .respiratoryRate)
165
231
  case "weight":
166
232
  return HKObjectType.quantityType(forIdentifier: .bodyMass)
167
233
  case "steps":
@@ -172,12 +238,30 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
172
238
  return HKObjectType.quantityType(forIdentifier: .height)
173
239
  case "distance":
174
240
  return HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning)
241
+ case "distance-cycling":
242
+ return HKObjectType.quantityType(forIdentifier: .distanceCycling)
175
243
  case "active-calories":
176
244
  return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
177
245
  case "total-calories":
178
246
  return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
247
+ case "basal-calories":
248
+ return HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
179
249
  case "blood-pressure":
180
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
@@ -317,6 +421,8 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
317
421
  return [HKObjectType.workoutType()].compactMap{$0}
318
422
  case "READ_HEART_RATE":
319
423
  return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap{$0}
424
+ case "READ_RESTING_HEART_RATE":
425
+ return [HKObjectType.quantityType(forIdentifier: .restingHeartRate)].compactMap { $0 }
320
426
  case "READ_ROUTE":
321
427
  return [HKSeriesType.workoutRoute()].compactMap{$0}
322
428
  case "READ_DISTANCE":
@@ -335,6 +441,28 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
335
441
  HKObjectType.quantityType(forIdentifier: .bloodPressureSystolic),
336
442
  HKObjectType.quantityType(forIdentifier: .bloodPressureDiastolic)
337
443
  ].compactMap { $0 }
444
+ case "READ_RESPIRATORY_RATE":
445
+ return [HKObjectType.quantityType(forIdentifier: .respiratoryRate)].compactMap { $0 }
446
+ case "READ_OXYGEN_SATURATION":
447
+ return [HKObjectType.quantityType(forIdentifier: .oxygenSaturation)].compactMap { $0 }
448
+ case "READ_BLOOD_GLUCOSE":
449
+ return [HKObjectType.quantityType(forIdentifier: .bloodGlucose)].compactMap { $0 }
450
+ case "READ_BODY_TEMPERATURE":
451
+ return [HKObjectType.quantityType(forIdentifier: .bodyTemperature)].compactMap { $0 }
452
+ case "READ_BASAL_BODY_TEMPERATURE":
453
+ return [HKObjectType.quantityType(forIdentifier: .basalBodyTemperature)].compactMap { $0 }
454
+ case "READ_BODY_FAT":
455
+ return [HKObjectType.quantityType(forIdentifier: .bodyFatPercentage)].compactMap { $0 }
456
+ case "READ_FLOORS_CLIMBED":
457
+ return [HKObjectType.quantityType(forIdentifier: .flightsClimbed)].compactMap { $0 }
458
+ case "READ_BASAL_CALORIES":
459
+ return [
460
+ HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
461
+ ].compactMap { $0 }
462
+ case "READ_SLEEP":
463
+ return [HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!].compactMap { $0 }
464
+ case "READ_EXERCISE_TIME":
465
+ return [HKObjectType.quantityType(forIdentifier: .appleExerciseTime)].compactMap { $0 }
338
466
  // Add common alternative permission names
339
467
  case "steps":
340
468
  return [HKObjectType.quantityType(forIdentifier: .stepCount)].compactMap{$0}
@@ -371,6 +499,26 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
371
499
  HKObjectType.quantityType(forIdentifier: .bloodPressureSystolic),
372
500
  HKObjectType.quantityType(forIdentifier: .bloodPressureDiastolic)
373
501
  ].compactMap { $0 }
502
+ case "respiratory-rate":
503
+ return [HKObjectType.quantityType(forIdentifier: .respiratoryRate)].compactMap { $0 }
504
+ case "oxygen-saturation":
505
+ return [HKObjectType.quantityType(forIdentifier: .oxygenSaturation)].compactMap { $0 }
506
+ case "blood-glucose":
507
+ return [HKObjectType.quantityType(forIdentifier: .bloodGlucose)].compactMap { $0 }
508
+ case "body-temperature":
509
+ return [HKObjectType.quantityType(forIdentifier: .bodyTemperature)].compactMap { $0 }
510
+ case "basal-body-temperature":
511
+ return [HKObjectType.quantityType(forIdentifier: .basalBodyTemperature)].compactMap { $0 }
512
+ case "body-fat":
513
+ return [HKObjectType.quantityType(forIdentifier: .bodyFatPercentage)].compactMap { $0 }
514
+ case "flights-climbed":
515
+ return [HKObjectType.quantityType(forIdentifier: .flightsClimbed)].compactMap { $0 }
516
+ case "basal-calories":
517
+ return [HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)].compactMap { $0 }
518
+ case "exercise-time":
519
+ return [HKObjectType.quantityType(forIdentifier: .appleExerciseTime)].compactMap { $0 }
520
+ case "sleep":
521
+ return [HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!].compactMap { $0 }
374
522
  default:
375
523
  print("⚡️ [HealthPlugin] Unknown permission: \(permission)")
376
524
  return []
@@ -385,16 +533,38 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
385
533
  return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
386
534
  case "heart-rate":
387
535
  return HKObjectType.quantityType(forIdentifier: .heartRate)
536
+ case "resting-heart-rate":
537
+ return HKObjectType.quantityType(forIdentifier: .restingHeartRate)
388
538
  case "weight":
389
539
  return HKObjectType.quantityType(forIdentifier: .bodyMass)
390
540
  case "hrv":
391
541
  return HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)
392
542
  case "distance":
393
543
  return HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning) // pick one rep type
544
+ case "distance-cycling":
545
+ return HKObjectType.quantityType(forIdentifier: .distanceCycling)
394
546
  case "total-calories":
395
547
  return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
548
+ case "basal-calories":
549
+ return HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
396
550
  case "height":
397
551
  return HKObjectType.quantityType(forIdentifier: .height)
552
+ case "respiratory-rate":
553
+ return HKObjectType.quantityType(forIdentifier: .respiratoryRate)
554
+ case "oxygen-saturation":
555
+ return HKObjectType.quantityType(forIdentifier: .oxygenSaturation)
556
+ case "blood-glucose":
557
+ return HKObjectType.quantityType(forIdentifier: .bloodGlucose)
558
+ case "body-temperature":
559
+ return HKObjectType.quantityType(forIdentifier: .bodyTemperature)
560
+ case "basal-body-temperature":
561
+ return HKObjectType.quantityType(forIdentifier: .basalBodyTemperature)
562
+ case "body-fat":
563
+ return HKObjectType.quantityType(forIdentifier: .bodyFatPercentage)
564
+ case "flights-climbed":
565
+ return HKObjectType.quantityType(forIdentifier: .flightsClimbed)
566
+ case "exercise-time":
567
+ return HKObjectType.quantityType(forIdentifier: .appleExerciseTime)
398
568
  default:
399
569
  return nil
400
570
  }
@@ -423,6 +593,16 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
423
593
  }
424
594
  }
425
595
  }
596
+ } else if dataTypeString == "sleep" {
597
+ self.querySleepAggregated(startDate: startDate, endDate: endDate) { result, error in
598
+ DispatchQueue.main.async {
599
+ if let error = error {
600
+ call.reject(error.localizedDescription)
601
+ } else if let result = result {
602
+ call.resolve(["aggregatedData": result])
603
+ }
604
+ }
605
+ }
426
606
  } else {
427
607
  let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
428
608
  guard let interval = calculateInterval(bucket: bucket) else {
@@ -441,7 +621,10 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
441
621
  switch dataType.aggregationStyle {
442
622
  case .cumulative:
443
623
  return .cumulativeSum
444
- case .discrete:
624
+ case .discrete,
625
+ .discreteArithmetic,
626
+ .discreteTemporallyWeighted,
627
+ .discreteEquivalentContinuousLevel:
445
628
  return .discreteAverage
446
629
  @unknown default:
447
630
  return .discreteAverage
@@ -471,12 +654,19 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
471
654
  let unit: HKUnit = {
472
655
  switch dataTypeString {
473
656
  case "steps": return .count()
474
- case "active-calories", "total-calories": return .kilocalorie()
475
- case "distance": return .meter()
657
+ case "active-calories", "total-calories", "basal-calories": return .kilocalorie()
658
+ case "distance", "distance-cycling": return .meter()
476
659
  case "weight": return .gramUnit(with: .kilo)
477
660
  case "height": return .meter()
478
- case "heart-rate": return HKUnit.count().unitDivided(by: HKUnit.minute())
661
+ case "heart-rate", "resting-heart-rate": return HKUnit.count().unitDivided(by: HKUnit.minute())
662
+ case "respiratory-rate": return HKUnit.count().unitDivided(by: HKUnit.minute())
479
663
  case "hrv": return HKUnit.secondUnit(with: .milli)
664
+ case "oxygen-saturation": return HKUnit.percent()
665
+ case "blood-glucose": return HKUnit(from: "mg/dL")
666
+ case "body-temperature", "basal-body-temperature": return HKUnit.degreeCelsius()
667
+ case "body-fat": return HKUnit.percent()
668
+ case "flights-climbed": return .count()
669
+ case "exercise-time": return HKUnit.minute()
480
670
  case "mindfulness": return HKUnit.second()
481
671
  default: return .count()
482
672
  }
@@ -532,6 +722,46 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
532
722
  }
533
723
  healthStore.execute(query)
534
724
  }
725
+
726
+ func querySleepAggregated(startDate: Date, endDate: Date, completion: @escaping ([[String: Any]]?, Error?) -> Void) {
727
+ guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
728
+ DispatchQueue.main.async {
729
+ completion(nil, NSError(domain: "HealthKit", code: -1, userInfo: [NSLocalizedDescriptionKey: "SleepAnalysis type unavailable"]))
730
+ }
731
+ return
732
+ }
733
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
734
+ let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
735
+ var dailyDurations: [Date: TimeInterval] = [:]
736
+ let calendar = Calendar.current
737
+ if let categorySamples = samples as? [HKCategorySample], error == nil {
738
+ for sample in categorySamples {
739
+ // Ignore in-bed samples; we care about actual sleep
740
+ if sample.value == HKCategoryValueSleepAnalysis.inBed.rawValue { continue }
741
+ let startOfDay = calendar.startOfDay(for: sample.startDate)
742
+ let duration = sample.endDate.timeIntervalSince(sample.startDate)
743
+ dailyDurations[startOfDay, default: 0] += duration
744
+ }
745
+ var aggregatedSamples: [[String: Any]] = []
746
+ let dayComponent = DateComponents(day: 1)
747
+ for (date, duration) in dailyDurations {
748
+ aggregatedSamples.append([
749
+ "startDate": date,
750
+ "endDate": calendar.date(byAdding: dayComponent, to: date) as Any,
751
+ "value": duration
752
+ ])
753
+ }
754
+ DispatchQueue.main.async {
755
+ completion(aggregatedSamples, nil)
756
+ }
757
+ } else {
758
+ DispatchQueue.main.async {
759
+ completion(nil, error)
760
+ }
761
+ }
762
+ }
763
+ healthStore.execute(query)
764
+ }
535
765
 
536
766
  private func queryAggregated(for startDate: Date, for endDate: Date, for dataType: HKQuantityType?, completion: @escaping (Double?) -> Void) {
537
767
  guard let quantityType = dataType else {
@@ -609,7 +839,7 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
609
839
  return
610
840
  }
611
841
  let outerGroup = DispatchGroup()
612
- let resultsQueue = DispatchQueue(label: "com.flomentum.healthplugin.workoutResults")
842
+ let resultsQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.workoutResults")
613
843
  var workoutResults: [[String: Any]] = []
614
844
  var errors: [String: String] = [:]
615
845
 
@@ -627,48 +857,54 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
627
857
  "distance": workout.totalDistance?.doubleValue(for: .meter()) ?? 0
628
858
  ]
629
859
  let innerGroup = DispatchGroup()
860
+ let heartRateQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.heartRates")
861
+ let routeQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.routes")
630
862
  var localHeartRates: [[String: Any]] = []
631
863
  var localRoutes: [[String: Any]] = []
632
864
 
633
865
  if includeHeartRate {
634
866
  innerGroup.enter()
635
867
  self.queryHeartRate(for: workout) { rates, error in
636
- localHeartRates = rates
637
- if let error = error {
638
- resultsQueue.async {
639
- errors["heart-rate"] = error
868
+ heartRateQueue.async {
869
+ localHeartRates = rates
870
+ if let error = error {
871
+ errors["heart-rate"] = error
640
872
  }
873
+ innerGroup.leave()
641
874
  }
642
- innerGroup.leave()
643
875
  }
644
876
  }
645
877
  if includeRoute {
646
878
  innerGroup.enter()
647
879
  self.queryRoute(for: workout) { routes, error in
648
- localRoutes = routes
649
- if let error = error {
650
- resultsQueue.async {
651
- errors["route"] = error
880
+ routeQueue.async {
881
+ localRoutes = routes
882
+ if let error = error {
883
+ errors["route"] = error
652
884
  }
885
+ innerGroup.leave()
653
886
  }
654
- innerGroup.leave()
655
887
  }
656
888
  }
657
889
  if includeSteps {
658
890
  innerGroup.enter()
659
891
  self.queryAggregated(for: workout.startDate, for: workout.endDate, for: HKObjectType.quantityType(forIdentifier: .stepCount)) { steps in
660
- if let steps = steps {
661
- localDict["steps"] = steps
892
+ resultsQueue.async {
893
+ if let steps = steps {
894
+ localDict["steps"] = steps
895
+ }
896
+ innerGroup.leave()
662
897
  }
663
- innerGroup.leave()
664
898
  }
665
899
  }
666
- innerGroup.notify(queue: .main) {
667
- localDict["heartRate"] = localHeartRates
668
- localDict["route"] = localRoutes
669
- resultsQueue.async {
670
- workoutResults.append(localDict)
900
+ innerGroup.notify(queue: resultsQueue) {
901
+ heartRateQueue.sync {
902
+ localDict["heartRate"] = localHeartRates
903
+ }
904
+ routeQueue.sync {
905
+ localDict["route"] = localRoutes
671
906
  }
907
+ workoutResults.append(localDict)
672
908
  outerGroup.leave()
673
909
  }
674
910
  }
@@ -721,7 +957,7 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
721
957
  guard let self = self else { return }
722
958
  if let routes = samples as? [HKWorkoutRoute], error == nil {
723
959
  let routeDispatchGroup = DispatchGroup()
724
- let allLocationsQueue = DispatchQueue(label: "com.flomentum.healthplugin.allLocations")
960
+ let allLocationsQueue = DispatchQueue(label: "com.flomentumsolutions.healthplugin.allLocations")
725
961
  var allLocations: [[String: Any]] = []
726
962
 
727
963
  for route in routes {
@@ -870,4 +1106,4 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
870
1106
  3000 : "other"
871
1107
  ]
872
1108
 
873
- }
1109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flomentumsolutions/capacitor-health-extended",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
@@ -13,13 +13,30 @@
13
13
  "ios/Sources",
14
14
  "ios/Tests",
15
15
  "Package.swift",
16
- "FlomentumsolutionsCapacitorHealthExtended.podspec"
16
+ "FlomentumSolutionsCapacitorHealthExtended.podspec"
17
17
  ],
18
- "author": "Flomentum Solutions, LLC",
18
+ "author": {
19
+ "name": "Flomentum Solutions, LLC",
20
+ "url": "https://github.com/Flomentum-Solutions"
21
+ },
19
22
  "license": "MIT",
23
+ "contributors": [
24
+ {
25
+ "name": "Michael Ley",
26
+ "url": "https://github.com/mley"
27
+ },
28
+ {
29
+ "name": "Flomentum Solutions, LLC",
30
+ "url": "https://github.com/Flomentum-Solutions"
31
+ },
32
+ {
33
+ "name": "MBRO95",
34
+ "url": "https://github.com/MBRO95"
35
+ }
36
+ ],
20
37
  "repository": {
21
38
  "type": "git",
22
- "url": "https://github.com/Flomentum-Solutions/capacitor-health-extended.git"
39
+ "url": "git+https://github.com/Flomentum-Solutions/capacitor-health-extended.git"
23
40
  },
24
41
  "bugs": {
25
42
  "url": "https://github.com/Flomentum-Solutions/capacitor-health-extended/issues"
@@ -36,7 +53,7 @@
36
53
  ],
37
54
  "scripts": {
38
55
  "verify": "npm run verify:ios && npm run verify:android && npm run verify:web",
39
- "verify:ios": "xcodebuild -scheme CapacitorHealthExtended -destination generic/platform=iOS",
56
+ "verify:ios": "xcodebuild -scheme FlomentumSolutionsCapacitorHealthExtended -destination generic/platform=iOS",
40
57
  "verify:android": "cd android && ./gradlew clean build test && cd ..",
41
58
  "verify:web": "npm run build",
42
59
  "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
@@ -51,23 +68,24 @@
51
68
  "prepublishOnly": "npm run build"
52
69
  },
53
70
  "devDependencies": {
54
- "@capacitor/android": "^7.0.0",
55
- "@capacitor/core": "^7.0.0",
56
- "@capacitor/docgen": "^0.3.0",
57
- "@capacitor/ios": "^7.0.0",
71
+ "@capacitor/android": "^8.0.0",
72
+ "@capacitor/cli": "^8.0.0",
73
+ "@capacitor/core": "^8.0.0",
74
+ "@capacitor/docgen": "^0.3.1",
75
+ "@capacitor/ios": "^8.0.0",
58
76
  "@ionic/eslint-config": "^0.4.0",
59
77
  "@ionic/prettier-config": "^4.0.0",
60
78
  "@ionic/swiftlint-config": "^2.0.0",
61
- "eslint": "^8.57.0",
62
- "prettier": "^3.4.2",
63
- "prettier-plugin-java": "^2.6.6",
64
- "rimraf": "^6.0.1",
65
- "rollup": "^4.30.1",
79
+ "eslint": "^8.57.1",
80
+ "prettier": "^3.6.2",
81
+ "prettier-plugin-java": "^2.7.7",
82
+ "rimraf": "^6.1.0",
83
+ "rollup": "^4.53.2",
66
84
  "swiftlint": "^2.0.0",
67
- "typescript": "~4.1.5"
85
+ "typescript": "^5.9.3"
68
86
  },
69
87
  "peerDependencies": {
70
- "@capacitor/core": ">=7.0.0"
88
+ "@capacitor/core": ">=8.0.0"
71
89
  },
72
90
  "prettier": "@ionic/prettier-config",
73
91
  "swiftlint": "@ionic/swiftlint-config",