@interval-health/capacitor-health 1.0.3 → 1.1.0

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/README.md CHANGED
@@ -173,7 +173,8 @@ export type HealthDataType =
173
173
  | 'mobility'
174
174
  | 'activity'
175
175
  | 'heart'
176
- | 'body';
176
+ | 'body'
177
+ | 'workout';
177
178
  ```
178
179
 
179
180
  | Data Type | Description | Unit | iOS | Android | Read | Write |
@@ -188,12 +189,13 @@ export type HealthDataType =
188
189
  | `activity` | Activity metrics | mixed | ✅ | ❌ | ✅ | ❌ |
189
190
  | `heart` | Heart health metrics | mixed | ✅ | ❌ | ✅ | ❌ |
190
191
  | `body` | Body measurements | mixed | ✅ | ❌ | ✅ | ❌ |
192
+ | `workout` | Workout/exercise sessions | minute | ✅ | ❌ | ✅ | ❌ |
191
193
 
192
194
  **Platform Support Notes**:
193
- - **iOS** supports all 10 data types
195
+ - **iOS** supports all 11 data types
194
196
  - **Android** supports only 5 basic data types: `steps`, `distance`, `calories`, `heartRate`, and `weight`
195
- - The `sleep`, `mobility`, `activity`, `heart`, and `body` types are **iOS-only** and not available on Android
196
- - Composite types (`mobility`, `activity`, `heart`, `body`) are **read-only** on iOS
197
+ - The `sleep`, `mobility`, `activity`, `heart`, `body`, and `workout` types are **iOS-only** and not available on Android
198
+ - Composite types (`mobility`, `activity`, `heart`, `body`, `workout`) are **read-only** on iOS
197
199
 
198
200
  ### Sleep States
199
201
 
@@ -209,6 +211,55 @@ When reading sleep data, each sample may include a `sleepState` property:
209
211
  | `asleepREM` | REM (Rapid Eye Movement) sleep stage |
210
212
  | `unknown` | Sleep state could not be determined |
211
213
 
214
+ ### Workout Data
215
+
216
+ When reading workout data (`dataType: 'workout'`), the returned data structure includes:
217
+
218
+ ```typescript
219
+ interface WorkoutData {
220
+ date: string; // ISO date (YYYY-MM-DD)
221
+ type: string; // Activity type (e.g., "Running", "Cycling", "Swimming")
222
+ duration: number; // Duration in minutes
223
+ distance?: number; // Distance in miles (optional)
224
+ calories?: number; // Calories burned (optional)
225
+ source?: string; // Source app name (optional)
226
+ avgHeartRate?: number; // Average heart rate in BPM (optional)
227
+ maxHeartRate?: number; // Maximum heart rate in BPM (optional)
228
+ zones?: { // Heart rate zones in minutes (optional)
229
+ zone1?: number; // 50-60% max HR
230
+ zone2?: number; // 60-70% max HR
231
+ zone3?: number; // 70-80% max HR
232
+ zone4?: number; // 80-90% max HR
233
+ zone5?: number; // 90-100% max HR
234
+ };
235
+ }
236
+ ```
237
+
238
+ **Supported Workout Types**: Running, Cycling, Walking, Swimming, Yoga, FunctionalStrengthTraining, TraditionalStrengthTraining, Elliptical, Rowing, Hiking, HighIntensityIntervalTraining, Dance, Basketball, Soccer, Tennis, Golf, StairClimbing, and more.
239
+
240
+ **Example**:
241
+ ```typescript
242
+ const result = await Health.readSamples({
243
+ dataType: 'workout',
244
+ startDate: '2024-01-01T00:00:00Z',
245
+ endDate: '2024-01-31T23:59:59Z',
246
+ limit: 50
247
+ });
248
+
249
+ // Sample output:
250
+ // {
251
+ // date: "2024-01-15",
252
+ // type: "Running",
253
+ // duration: 45,
254
+ // distance: 5.23,
255
+ // calories: 450,
256
+ // source: "Apple Watch",
257
+ // avgHeartRate: 145,
258
+ // maxHeartRate: 175,
259
+ // zones: { zone2: 10, zone3: 20, zone4: 15 }
260
+ // }
261
+ ```
262
+
212
263
  ---
213
264
 
214
265
  ## 🔧 Methods
@@ -3,6 +3,7 @@ package app.capgo.plugin.health
3
3
  import androidx.health.connect.client.permission.HealthPermission
4
4
  import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
5
5
  import androidx.health.connect.client.records.DistanceRecord
6
+ import androidx.health.connect.client.records.ExerciseSessionRecord
6
7
  import androidx.health.connect.client.records.HeartRateRecord
7
8
  import androidx.health.connect.client.records.Record
8
9
  import androidx.health.connect.client.records.SleepSessionRecord
@@ -21,7 +22,8 @@ enum class HealthDataType(
21
22
  HEART_RATE("heartRate", HeartRateRecord::class, "bpm"),
22
23
  WEIGHT("weight", WeightRecord::class, "kilogram"),
23
24
  SLEEP("sleep", SleepSessionRecord::class, "minute"),
24
- MOBILITY("mobility", StepsRecord::class, "mixed"); // Using StepsRecord as placeholder
25
+ MOBILITY("mobility", StepsRecord::class, "mixed"), // Using StepsRecord as placeholder
26
+ WORKOUT("workout", ExerciseSessionRecord::class, "minute");
25
27
 
26
28
  val readPermission: String
27
29
  get() = HealthPermission.getReadPermission(recordClass)
package/dist/docs.json CHANGED
@@ -456,6 +456,10 @@
456
456
  {
457
457
  "text": "'body'",
458
458
  "complexTypes": []
459
+ },
460
+ {
461
+ "text": "'workout'",
462
+ "complexTypes": []
459
463
  }
460
464
  ]
461
465
  },
@@ -1,6 +1,24 @@
1
- export type HealthDataType = 'steps' | 'distance' | 'calories' | 'heartRate' | 'weight' | 'sleep' | 'mobility' | 'activity' | 'heart' | 'body';
1
+ export type HealthDataType = 'steps' | 'distance' | 'calories' | 'heartRate' | 'weight' | 'sleep' | 'mobility' | 'activity' | 'heart' | 'body' | 'workout';
2
2
  export type HealthUnit = 'count' | 'meter' | 'kilocalorie' | 'bpm' | 'kilogram' | 'minute' | 'mixed';
3
3
  export type SleepState = 'inBed' | 'asleep' | 'awake' | 'asleepCore' | 'asleepDeep' | 'asleepREM' | 'unknown';
4
+ export interface WorkoutHeartRateZones {
5
+ zone1?: number;
6
+ zone2?: number;
7
+ zone3?: number;
8
+ zone4?: number;
9
+ zone5?: number;
10
+ }
11
+ export interface WorkoutData {
12
+ date: string;
13
+ type: string;
14
+ duration: number;
15
+ distance?: number;
16
+ calories?: number;
17
+ source?: string;
18
+ avgHeartRate?: number;
19
+ maxHeartRate?: number;
20
+ zones?: WorkoutHeartRateZones;
21
+ }
4
22
  export interface AuthorizationOptions {
5
23
  /** Data types that should be readable after authorization. */
6
24
  read?: HealthDataType[];
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":[" export type HealthDataType = 'steps' | 'distance' | 'calories' | 'heartRate' | 'weight' | 'sleep' | 'mobility' | 'activity' | 'heart' | 'body';\n\nexport type HealthUnit = 'count' | 'meter' | 'kilocalorie' | 'bpm' | 'kilogram' | 'minute' | 'mixed';\nexport type SleepState = 'inBed' | 'asleep' | 'awake' | 'asleepCore' | 'asleepDeep' | 'asleepREM' | 'unknown';\nexport interface AuthorizationOptions {\n /** Data types that should be readable after authorization. */\n read?: HealthDataType[];\n /** Data types that should be writable after authorization. */\n write?: HealthDataType[];\n}\n\nexport interface AuthorizationStatus {\n readAuthorized: HealthDataType[];\n readDenied: HealthDataType[];\n writeAuthorized: HealthDataType[];\n writeDenied: HealthDataType[];\n}\n\nexport interface AvailabilityResult {\n available: boolean;\n /** Platform specific details (for debugging/diagnostics). */\n platform?: 'ios' | 'android' | 'web';\n reason?: string;\n}\n\nexport interface QueryOptions {\n /** The type of data to retrieve from the health store. */\n dataType: HealthDataType;\n /** Inclusive ISO 8601 start date (defaults to now - 1 day). */\n startDate?: string;\n /** Exclusive ISO 8601 end date (defaults to now). */\n endDate?: string;\n /** Maximum number of samples to return (defaults to 100). */\n limit?: number;\n /** Return results sorted ascending by start date (defaults to false). */\n ascending?: boolean;\n}\n\nexport interface HealthSample {\n dataType: HealthDataType;\n value: number;\n unit: HealthUnit;\n startDate: string;\n endDate: string;\n sourceName?: string;\n sourceId?: string;\n /** Sleep state (only present when dataType is 'sleep') */\n sleepState?: SleepState;\n}\n\nexport interface ReadSamplesResult {\n samples: HealthSample[];\n}\n\nexport interface WriteSampleOptions {\n dataType: HealthDataType;\n value: number;\n /**\n * Optional unit override. If omitted, the default unit for the data type is used\n * (count for `steps`, meter for `distance`, kilocalorie for `calories`, bpm for `heartRate`, kilogram for `weight`, minute for `sleep`).\n */\n unit?: HealthUnit;\n /** ISO 8601 start date for the sample. Defaults to now. */\n startDate?: string;\n /** ISO 8601 end date for the sample. Defaults to startDate. */\n endDate?: string;\n /** Metadata key-value pairs forwarded to the native APIs where supported. */\n metadata?: Record<string, string>;\n}\n\nexport interface HealthPlugin {\n /** Returns whether the current platform supports the native health SDK. */\n isAvailable(): Promise<AvailabilityResult>;\n /** Requests read/write access to the provided data types. */\n requestAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Checks authorization status for the provided data types without prompting the user. */\n checkAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Reads samples for the given data type within the specified time frame. */\n readSamples(options: QueryOptions): Promise<ReadSamplesResult>;\n /** Writes a single sample to the native health store. */\n saveSample(options: WriteSampleOptions): Promise<void>;\n\n /**\n * Get the native Capacitor plugin version\n *\n * @returns {Promise<{ id: string }>} an Promise with version for this device\n * @throws An error if the something went wrong\n */\n getPluginVersion(): Promise<{ version: string }>;\n}\n"]}
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":[" export type HealthDataType = 'steps' | 'distance' | 'calories' | 'heartRate' | 'weight' | 'sleep' | 'mobility' | 'activity' | 'heart' | 'body' | 'workout';\n\nexport type HealthUnit = 'count' | 'meter' | 'kilocalorie' | 'bpm' | 'kilogram' | 'minute' | 'mixed';\nexport type SleepState = 'inBed' | 'asleep' | 'awake' | 'asleepCore' | 'asleepDeep' | 'asleepREM' | 'unknown';\n\nexport interface WorkoutHeartRateZones {\n zone1?: number; // 50-60% max HR, in minutes\n zone2?: number; // 60-70% max HR, in minutes\n zone3?: number; // 70-80% max HR, in minutes\n zone4?: number; // 80-90% max HR, in minutes\n zone5?: number; // 90-100% max HR, in minutes\n}\n\nexport interface WorkoutData {\n date: string; // ISO date (YYYY-MM-DD)\n type: string; // Activity type (e.g., \"Running\", \"Cycling\")\n duration: number; // Duration in minutes\n distance?: number; // Distance in miles\n calories?: number; // Calories burned\n source?: string; // Source app name\n avgHeartRate?: number; // Average heart rate in BPM\n maxHeartRate?: number; // Maximum heart rate in BPM\n zones?: WorkoutHeartRateZones; // Heart rate zones\n}\nexport interface AuthorizationOptions {\n /** Data types that should be readable after authorization. */\n read?: HealthDataType[];\n /** Data types that should be writable after authorization. */\n write?: HealthDataType[];\n}\n\nexport interface AuthorizationStatus {\n readAuthorized: HealthDataType[];\n readDenied: HealthDataType[];\n writeAuthorized: HealthDataType[];\n writeDenied: HealthDataType[];\n}\n\nexport interface AvailabilityResult {\n available: boolean;\n /** Platform specific details (for debugging/diagnostics). */\n platform?: 'ios' | 'android' | 'web';\n reason?: string;\n}\n\nexport interface QueryOptions {\n /** The type of data to retrieve from the health store. */\n dataType: HealthDataType;\n /** Inclusive ISO 8601 start date (defaults to now - 1 day). */\n startDate?: string;\n /** Exclusive ISO 8601 end date (defaults to now). */\n endDate?: string;\n /** Maximum number of samples to return (defaults to 100). */\n limit?: number;\n /** Return results sorted ascending by start date (defaults to false). */\n ascending?: boolean;\n}\n\nexport interface HealthSample {\n dataType: HealthDataType;\n value: number;\n unit: HealthUnit;\n startDate: string;\n endDate: string;\n sourceName?: string;\n sourceId?: string;\n /** Sleep state (only present when dataType is 'sleep') */\n sleepState?: SleepState;\n}\n\nexport interface ReadSamplesResult {\n samples: HealthSample[];\n}\n\nexport interface WriteSampleOptions {\n dataType: HealthDataType;\n value: number;\n /**\n * Optional unit override. If omitted, the default unit for the data type is used\n * (count for `steps`, meter for `distance`, kilocalorie for `calories`, bpm for `heartRate`, kilogram for `weight`, minute for `sleep`).\n */\n unit?: HealthUnit;\n /** ISO 8601 start date for the sample. Defaults to now. */\n startDate?: string;\n /** ISO 8601 end date for the sample. Defaults to startDate. */\n endDate?: string;\n /** Metadata key-value pairs forwarded to the native APIs where supported. */\n metadata?: Record<string, string>;\n}\n\nexport interface HealthPlugin {\n /** Returns whether the current platform supports the native health SDK. */\n isAvailable(): Promise<AvailabilityResult>;\n /** Requests read/write access to the provided data types. */\n requestAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Checks authorization status for the provided data types without prompting the user. */\n checkAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Reads samples for the given data type within the specified time frame. */\n readSamples(options: QueryOptions): Promise<ReadSamplesResult>;\n /** Writes a single sample to the native health store. */\n saveSample(options: WriteSampleOptions): Promise<void>;\n\n /**\n * Get the native Capacitor plugin version\n *\n * @returns {Promise<{ id: string }>} an Promise with version for this device\n * @throws An error if the something went wrong\n */\n getPluginVersion(): Promise<{ version: string }>;\n}\n"]}
@@ -38,6 +38,7 @@ enum HealthDataType: String, CaseIterable {
38
38
  case activity
39
39
  case heart
40
40
  case body
41
+ case workout
41
42
 
42
43
  func sampleType() throws -> HKSampleType {
43
44
  if self == .sleep {
@@ -79,6 +80,11 @@ enum HealthDataType: String, CaseIterable {
79
80
  return type
80
81
  }
81
82
 
83
+ if self == .workout {
84
+ // Workout uses HKWorkoutType
85
+ return HKObjectType.workoutType()
86
+ }
87
+
82
88
  let identifier: HKQuantityTypeIdentifier
83
89
  switch self {
84
90
  case .steps:
@@ -101,6 +107,8 @@ enum HealthDataType: String, CaseIterable {
101
107
  fatalError("Heart should have been handled above")
102
108
  case .body:
103
109
  fatalError("Body should have been handled above")
110
+ case .workout:
111
+ fatalError("Workout should have been handled above")
104
112
  }
105
113
 
106
114
  guard let type = HKObjectType.quantityType(forIdentifier: identifier) else {
@@ -131,6 +139,8 @@ enum HealthDataType: String, CaseIterable {
131
139
  return HKUnit.count().unitDivided(by: HKUnit.minute()) // Placeholder, heart has multiple units
132
140
  case .body:
133
141
  return HKUnit.gramUnit(with: .kilo) // Placeholder, body has multiple units
142
+ case .workout:
143
+ return HKUnit.minute() // Workout duration in minutes
134
144
  }
135
145
  }
136
146
 
@@ -156,6 +166,8 @@ enum HealthDataType: String, CaseIterable {
156
166
  return "mixed" // Heart has multiple units
157
167
  case .body:
158
168
  return "mixed" // Body has multiple units
169
+ case .workout:
170
+ return "minute" // Workout duration in minutes
159
171
  }
160
172
  }
161
173
 
@@ -324,6 +336,19 @@ final class Health {
324
336
  return
325
337
  }
326
338
 
339
+ // Handle workout data
340
+ if dataType == .workout {
341
+ processWorkoutData(startDate: startDate, endDate: endDate, limit: limit, ascending: ascending) { result in
342
+ switch result {
343
+ case .success(let workoutData):
344
+ completion(.success(workoutData))
345
+ case .failure(let error):
346
+ completion(.failure(error))
347
+ }
348
+ }
349
+ return
350
+ }
351
+
327
352
  // For all other data types, use the standard query approach
328
353
  let sampleType = try dataType.sampleType()
329
354
  let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
@@ -2060,4 +2085,302 @@ final class Health {
2060
2085
  completion(.success(heartData))
2061
2086
  }
2062
2087
  }
2088
+
2089
+ // MARK: - Workout Data Processing
2090
+
2091
+ private func processWorkoutData(startDate: Date, endDate: Date, limit: Int?, ascending: Bool, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
2092
+ let workoutType = HKObjectType.workoutType()
2093
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
2094
+ let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: ascending)
2095
+ let queryLimit = limit ?? HKObjectQueryNoLimit
2096
+
2097
+ let query = HKSampleQuery(sampleType: workoutType, predicate: predicate, limit: queryLimit, sortDescriptors: [sortDescriptor]) { [weak self] _, samples, error in
2098
+ guard let self = self else { return }
2099
+
2100
+ if let error = error {
2101
+ completion(.failure(error))
2102
+ return
2103
+ }
2104
+
2105
+ guard let workouts = samples as? [HKWorkout], !workouts.isEmpty else {
2106
+ completion(.success([]))
2107
+ return
2108
+ }
2109
+
2110
+ var workoutData: [[String: Any]] = []
2111
+ let group = DispatchGroup()
2112
+ let lock = NSLock()
2113
+
2114
+ for workout in workouts {
2115
+ group.enter()
2116
+
2117
+ // Extract basic workout info
2118
+ let dateFormatter = ISO8601DateFormatter()
2119
+ dateFormatter.formatOptions = [.withFullDate]
2120
+ let date = dateFormatter.string(from: workout.startDate)
2121
+
2122
+ // Get workout type (remove "HKWorkoutActivityType" prefix to match XML format)
2123
+ let activityTypeString = self.workoutActivityTypeString(for: workout.workoutActivityType)
2124
+
2125
+ // Duration in minutes (matching XML parser logic)
2126
+ let durationInMinutes = Int(round(workout.duration / 60.0))
2127
+
2128
+ // Distance in miles (if available)
2129
+ var distance: Double?
2130
+ if let totalDistance = workout.totalDistance {
2131
+ let distanceInMeters = totalDistance.doubleValue(for: HKUnit.meter())
2132
+ distance = distanceInMeters * 0.000621371 // meters to miles
2133
+ }
2134
+
2135
+ // Calories (if available)
2136
+ var calories: Int?
2137
+ if let totalEnergy = workout.totalEnergyBurned {
2138
+ let caloriesValue = totalEnergy.doubleValue(for: HKUnit.kilocalorie())
2139
+ calories = Int(round(caloriesValue))
2140
+ }
2141
+
2142
+ // Source name
2143
+ let source = workout.sourceRevision.source.name
2144
+
2145
+ // Query for heart rate statistics during this workout
2146
+ self.queryWorkoutHeartRateStatistics(for: workout) { avgHR, maxHR in
2147
+ // Query for heart rate zones
2148
+ self.queryWorkoutHeartRateZones(for: workout) { zones in
2149
+ lock.lock()
2150
+
2151
+ var workoutDict: [String: Any] = [
2152
+ "date": date,
2153
+ "type": activityTypeString,
2154
+ "duration": durationInMinutes,
2155
+ "source": source
2156
+ ]
2157
+
2158
+ if let distance = distance {
2159
+ workoutDict["distance"] = round(distance * 100) / 100 // 2 decimal places
2160
+ }
2161
+
2162
+ if let calories = calories {
2163
+ workoutDict["calories"] = calories
2164
+ }
2165
+
2166
+ if let avgHR = avgHR {
2167
+ workoutDict["avgHeartRate"] = avgHR
2168
+ }
2169
+
2170
+ if let maxHR = maxHR {
2171
+ workoutDict["maxHeartRate"] = maxHR
2172
+ }
2173
+
2174
+ if !zones.isEmpty {
2175
+ workoutDict["zones"] = zones
2176
+ }
2177
+
2178
+ workoutData.append(workoutDict)
2179
+ lock.unlock()
2180
+
2181
+ group.leave()
2182
+ }
2183
+ }
2184
+ }
2185
+
2186
+ group.notify(queue: .main) {
2187
+ // Sort by date if needed
2188
+ let sortedData = workoutData.sorted { dict1, dict2 in
2189
+ guard let date1 = dict1["date"] as? String,
2190
+ let date2 = dict2["date"] as? String else {
2191
+ return false
2192
+ }
2193
+ return ascending ? date1 < date2 : date1 > date2
2194
+ }
2195
+
2196
+ completion(.success(sortedData))
2197
+ }
2198
+ }
2199
+
2200
+ healthStore.execute(query)
2201
+ }
2202
+
2203
+ private func workoutActivityTypeString(for type: HKWorkoutActivityType) -> String {
2204
+ // Return the activity type name without the "HKWorkoutActivityType" prefix
2205
+ // to match the XML export format
2206
+ switch type {
2207
+ case .running: return "Running"
2208
+ case .cycling: return "Cycling"
2209
+ case .walking: return "Walking"
2210
+ case .swimming: return "Swimming"
2211
+ case .yoga: return "Yoga"
2212
+ case .functionalStrengthTraining: return "FunctionalStrengthTraining"
2213
+ case .traditionalStrengthTraining: return "TraditionalStrengthTraining"
2214
+ case .elliptical: return "Elliptical"
2215
+ case .rowing: return "Rowing"
2216
+ case .hiking: return "Hiking"
2217
+ case .highIntensityIntervalTraining: return "HighIntensityIntervalTraining"
2218
+ case .dance: return "Dance"
2219
+ case .basketball: return "Basketball"
2220
+ case .soccer: return "Soccer"
2221
+ case .tennis: return "Tennis"
2222
+ case .golf: return "Golf"
2223
+ case .stairClimbing: return "StairClimbing"
2224
+ case .stepTraining: return "StepTraining"
2225
+ case .kickboxing: return "Kickboxing"
2226
+ case .pilates: return "Pilates"
2227
+ case .boxing: return "Boxing"
2228
+ case .taiChi: return "TaiChi"
2229
+ case .crossTraining: return "CrossTraining"
2230
+ case .mindAndBody: return "MindAndBody"
2231
+ case .coreTraining: return "CoreTraining"
2232
+ case .flexibility: return "Flexibility"
2233
+ case .cooldown: return "Cooldown"
2234
+ case .wheelchairWalkPace: return "WheelchairWalkPace"
2235
+ case .wheelchairRunPace: return "WheelchairRunPace"
2236
+ case .other: return "Other"
2237
+ default:
2238
+ // For any unknown or new types
2239
+ return "Other"
2240
+ }
2241
+ }
2242
+
2243
+ private func queryWorkoutHeartRateStatistics(for workout: HKWorkout, completion: @escaping (Int?, Int?) -> Void) {
2244
+ guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) else {
2245
+ completion(nil, nil)
2246
+ return
2247
+ }
2248
+
2249
+ let predicate = HKQuery.predicateForSamples(withStart: workout.startDate, end: workout.endDate, options: .strictStartDate)
2250
+
2251
+ let query = HKStatisticsQuery(quantityType: heartRateType, quantitySamplePredicate: predicate, options: [.discreteAverage, .discreteMax]) { _, statistics, error in
2252
+ if error != nil {
2253
+ completion(nil, nil)
2254
+ return
2255
+ }
2256
+
2257
+ var avgHR: Int?
2258
+ var maxHR: Int?
2259
+
2260
+ if let avgQuantity = statistics?.averageQuantity() {
2261
+ let bpm = avgQuantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
2262
+ avgHR = Int(round(bpm))
2263
+ }
2264
+
2265
+ if let maxQuantity = statistics?.maximumQuantity() {
2266
+ let bpm = maxQuantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
2267
+ maxHR = Int(round(bpm))
2268
+ }
2269
+
2270
+ completion(avgHR, maxHR)
2271
+ }
2272
+
2273
+ healthStore.execute(query)
2274
+ }
2275
+
2276
+ private func queryWorkoutHeartRateZones(for workout: HKWorkout, completion: @escaping ([String: Int]) -> Void) {
2277
+ // Check if heart rate zone data is available in workout metadata
2278
+ var zones: [String: Int] = [:]
2279
+
2280
+ if let metadata = workout.metadata {
2281
+ // Look for heart rate zone keys in metadata
2282
+ // Apple Health stores zones as HKMetadataKeyHeartRateEventThreshold or custom keys
2283
+ for (key, value) in metadata {
2284
+ if key.contains("Zone") || key.contains("zone") {
2285
+ // Try to extract zone number
2286
+ if let zoneMatch = key.range(of: #"\d+"#, options: .regularExpression),
2287
+ let zoneNum = Int(key[zoneMatch]) {
2288
+ // Value might be in seconds, convert to minutes
2289
+ if let seconds = value as? Double {
2290
+ let minutes = Int(round(seconds / 60.0))
2291
+ zones["zone\(zoneNum)"] = minutes
2292
+ } else if let minutes = value as? Int {
2293
+ zones["zone\(zoneNum)"] = minutes
2294
+ }
2295
+ }
2296
+ }
2297
+ }
2298
+ }
2299
+
2300
+ // If no zones found in metadata, try querying heart rate samples to calculate zones
2301
+ if zones.isEmpty {
2302
+ calculateHeartRateZones(for: workout) { calculatedZones in
2303
+ completion(calculatedZones)
2304
+ }
2305
+ } else {
2306
+ completion(zones)
2307
+ }
2308
+ }
2309
+
2310
+ private func calculateHeartRateZones(for workout: HKWorkout, completion: @escaping ([String: Int]) -> Void) {
2311
+ guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) else {
2312
+ completion([:])
2313
+ return
2314
+ }
2315
+
2316
+ let predicate = HKQuery.predicateForSamples(withStart: workout.startDate, end: workout.endDate, options: .strictStartDate)
2317
+ let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
2318
+
2319
+ let query = HKSampleQuery(sampleType: heartRateType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sortDescriptor]) { _, samples, error in
2320
+ if error != nil || samples == nil {
2321
+ completion([:])
2322
+ return
2323
+ }
2324
+
2325
+ guard let heartRateSamples = samples as? [HKQuantitySample], !heartRateSamples.isEmpty else {
2326
+ completion([:])
2327
+ return
2328
+ }
2329
+
2330
+ // Calculate zones based on standard heart rate zone definitions
2331
+ // Zone 1: 50-60% max HR
2332
+ // Zone 2: 60-70% max HR
2333
+ // Zone 3: 70-80% max HR
2334
+ // Zone 4: 80-90% max HR
2335
+ // Zone 5: 90-100% max HR
2336
+
2337
+ // Estimate max HR (220 - age), or use 180 as a reasonable default
2338
+ let estimatedMaxHR = 180.0
2339
+
2340
+ var zoneMinutes: [Int: TimeInterval] = [1: 0, 2: 0, 3: 0, 4: 0, 5: 0]
2341
+
2342
+ for i in 0..<heartRateSamples.count {
2343
+ let sample = heartRateSamples[i]
2344
+ let bpm = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: HKUnit.minute()))
2345
+ let percentMax = (bpm / estimatedMaxHR) * 100
2346
+
2347
+ // Determine zone
2348
+ let zone: Int
2349
+ if percentMax < 60 {
2350
+ zone = 1
2351
+ } else if percentMax < 70 {
2352
+ zone = 2
2353
+ } else if percentMax < 80 {
2354
+ zone = 3
2355
+ } else if percentMax < 90 {
2356
+ zone = 4
2357
+ } else {
2358
+ zone = 5
2359
+ }
2360
+
2361
+ // Calculate time in this zone (use interval to next sample or default to 5 seconds)
2362
+ let duration: TimeInterval
2363
+ if i < heartRateSamples.count - 1 {
2364
+ duration = heartRateSamples[i + 1].startDate.timeIntervalSince(sample.startDate)
2365
+ } else {
2366
+ duration = 5.0 // Default 5 seconds for last sample
2367
+ }
2368
+
2369
+ zoneMinutes[zone, default: 0] += duration
2370
+ }
2371
+
2372
+ // Convert seconds to minutes and filter out zones with 0 minutes
2373
+ var zones: [String: Int] = [:]
2374
+ for (zone, seconds) in zoneMinutes {
2375
+ let minutes = Int(round(seconds / 60.0))
2376
+ if minutes > 0 {
2377
+ zones["zone\(zone)"] = minutes
2378
+ }
2379
+ }
2380
+
2381
+ completion(zones)
2382
+ }
2383
+
2384
+ healthStore.execute(query)
2385
+ }
2063
2386
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@interval-health/capacitor-health",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Capacitor plugin to interact with data from Apple HealthKit and Health Connect",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",