@flomentumsolutions/capacitor-health-extended 0.4.2 → 0.4.4

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
@@ -334,7 +334,7 @@ Query aggregated data
334
334
  **Returns:** <code>Promise&lt;<a href="#queryaggregatedresponse">QueryAggregatedResponse</a>&gt;</code>
335
335
 
336
336
  - Blood-pressure aggregates return the systolic average in `value` plus `systolic`, `diastolic`, and `unit`.
337
- - On iOS `total-calories` is derived as active + basal energy (Android uses Health Connect's total calories when available).
337
+ - `total-calories` is derived as active + basal energy on both iOS and Android for latest samples, aggregated queries, and workouts. We fall back to the platform's total‑calories metric (or active calories) when basal data isn't available or permission is missing. Request both `READ_ACTIVE_CALORIES` and `READ_BASAL_CALORIES` for full totals.
338
338
  - Weight/height aggregation returns the latest sample per day (no averaging).
339
339
  - Android aggregation currently supports daily buckets; unsupported buckets will be rejected.
340
340
  - Android `distance-cycling` aggregates distance recorded during biking exercise sessions (requires distance + workouts permissions).
@@ -374,6 +374,8 @@ Query latest sample for a specific data type
374
374
 
375
375
  **Returns:** <code>Promise&lt;<a href="#querylatestsampleresponse">QueryLatestSampleResponse</a>&gt;</code>
376
376
 
377
+ - Latest sleep sample returns the most recent complete sleep session (asleep states only) from the last ~36 hours; if a longer overnight session exists, shorter naps are ignored.
378
+
377
379
  --------------------
378
380
 
379
381
 
@@ -804,7 +804,72 @@ class HealthPlugin : Plugin() {
804
804
  }
805
805
  }
806
806
 
807
+ private suspend fun sumActiveAndBasalCalories(
808
+ timeRange: TimeRangeFilter,
809
+ includeTotalMetricFallback: Boolean = false
810
+ ): Double? {
811
+ val metrics = mutableSetOf<AggregateMetric<*>>()
812
+ val includeActive = hasPermission(CapHealthPermission.READ_ACTIVE_CALORIES)
813
+ val includeBasal = hasPermission(CapHealthPermission.READ_BASAL_CALORIES)
814
+ val includeTotal = includeTotalMetricFallback && hasPermission(CapHealthPermission.READ_TOTAL_CALORIES)
815
+
816
+ if (includeActive) metrics.add(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL)
817
+ if (includeBasal) metrics.add(BasalMetabolicRateRecord.BASAL_CALORIES_TOTAL)
818
+ if (includeTotal) metrics.add(TotalCaloriesBurnedRecord.ENERGY_TOTAL)
819
+
820
+ if (metrics.isEmpty()) {
821
+ return null
822
+ }
823
+
824
+ val aggregation = healthConnectClient.aggregate(
825
+ AggregateRequest(
826
+ metrics = metrics,
827
+ timeRangeFilter = timeRange,
828
+ dataOriginFilter = emptySet()
829
+ )
830
+ )
831
+
832
+ val active = if (includeActive) {
833
+ aggregation[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
834
+ } else null
835
+ val basal = if (includeBasal) {
836
+ aggregation[BasalMetabolicRateRecord.BASAL_CALORIES_TOTAL]?.inKilocalories
837
+ } else null
838
+ val total = if (includeTotal) {
839
+ aggregation[TotalCaloriesBurnedRecord.ENERGY_TOTAL]?.inKilocalories
840
+ } else null
841
+
842
+ return when {
843
+ active != null || basal != null -> (active ?: 0.0) + (basal ?: 0.0)
844
+ includeTotal -> total
845
+ else -> null
846
+ }
847
+ }
848
+
807
849
  private suspend fun readLatestTotalCalories(): JSObject {
850
+ val zone = ZoneId.systemDefault()
851
+ val now = LocalDateTime.now(zone)
852
+
853
+ val derivedTotal = try {
854
+ sumActiveAndBasalCalories(
855
+ TimeRangeFilter.between(
856
+ now.toLocalDate().atStartOfDay(),
857
+ now
858
+ )
859
+ )
860
+ } catch (e: Exception) {
861
+ Log.w(tag, "readLatestTotalCalories: Failed to derive active+basal calories, falling back to total metric", e)
862
+ null
863
+ }
864
+
865
+ if (derivedTotal != null) {
866
+ return JSObject().apply {
867
+ put("value", derivedTotal)
868
+ put("timestamp", now.atZone(zone).toInstant().toEpochMilli())
869
+ put("unit", "kcal")
870
+ }
871
+ }
872
+
808
873
  if (!hasPermission(CapHealthPermission.READ_TOTAL_CALORIES)) {
809
874
  throw Exception("Permission for total calories not granted")
810
875
  }
@@ -952,13 +1017,17 @@ class HealthPlugin : Plugin() {
952
1017
  }
953
1018
 
954
1019
  else -> {
955
- val metricAndMapper = getMetricAndMapper(dataType)
956
- val r = queryAggregatedMetric(
957
- metricAndMapper,
958
- timeRange,
959
- period
960
- )
961
- r.forEach { aggregatedList.put(it.toJs()) }
1020
+ val aggregated = if (dataType == "total-calories") {
1021
+ aggregateTotalCalories(timeRange, period)
1022
+ } else {
1023
+ val metricAndMapper = getMetricAndMapper(dataType)
1024
+ queryAggregatedMetric(
1025
+ metricAndMapper,
1026
+ timeRange,
1027
+ period
1028
+ )
1029
+ }
1030
+ aggregated.forEach { aggregatedList.put(it.toJs()) }
962
1031
  }
963
1032
  }
964
1033
 
@@ -1068,6 +1137,51 @@ class HealthPlugin : Plugin() {
1068
1137
 
1069
1138
  }
1070
1139
 
1140
+ private suspend fun aggregateTotalCalories(
1141
+ timeRange: TimeRangeFilter,
1142
+ period: Period
1143
+ ): List<AggregatedSample> {
1144
+ val includeActive = hasPermission(CapHealthPermission.READ_ACTIVE_CALORIES)
1145
+ val includeBasal = hasPermission(CapHealthPermission.READ_BASAL_CALORIES)
1146
+ val includeTotal = hasPermission(CapHealthPermission.READ_TOTAL_CALORIES)
1147
+
1148
+ val metrics = mutableSetOf<AggregateMetric<*>>()
1149
+ if (includeActive) metrics.add(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL)
1150
+ if (includeBasal) metrics.add(BasalMetabolicRateRecord.BASAL_CALORIES_TOTAL)
1151
+ if (includeTotal) metrics.add(TotalCaloriesBurnedRecord.ENERGY_TOTAL)
1152
+
1153
+ if (metrics.isEmpty()) {
1154
+ return emptyList()
1155
+ }
1156
+
1157
+ val response: List<AggregationResultGroupedByPeriod> = healthConnectClient.aggregateGroupByPeriod(
1158
+ AggregateGroupByPeriodRequest(
1159
+ metrics = metrics,
1160
+ timeRangeFilter = timeRange,
1161
+ timeRangeSlicer = period
1162
+ )
1163
+ )
1164
+
1165
+ return response.map {
1166
+ val active = if (includeActive) {
1167
+ it.result[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
1168
+ } else null
1169
+ val basal = if (includeBasal) {
1170
+ it.result[BasalMetabolicRateRecord.BASAL_CALORIES_TOTAL]?.inKilocalories
1171
+ } else null
1172
+ val total = if (includeTotal) {
1173
+ it.result[TotalCaloriesBurnedRecord.ENERGY_TOTAL]?.inKilocalories
1174
+ } else null
1175
+
1176
+ val value = when {
1177
+ active != null || basal != null -> (active ?: 0.0) + (basal ?: 0.0)
1178
+ else -> total ?: 0.0
1179
+ }
1180
+
1181
+ AggregatedSample(it.startTime, it.endTime, value)
1182
+ }
1183
+ }
1184
+
1071
1185
  private suspend fun aggregateHrvByPeriod(
1072
1186
  timeRange: TimeRangeFilter,
1073
1187
  period: Period
@@ -1409,9 +1523,12 @@ class HealthPlugin : Plugin() {
1409
1523
  addWorkoutMetric(workout, workoutObject, getMetricAndMapper("steps"))
1410
1524
  }
1411
1525
 
1412
- val readTotalCaloriesResult = addWorkoutMetric(workout, workoutObject, getMetricAndMapper("total-calories"))
1413
- if(!readTotalCaloriesResult) {
1414
- addWorkoutMetric(workout, workoutObject, getMetricAndMapper("active-calories"))
1526
+ val derivedTotalAdded = addWorkoutTotalCalories(workout, workoutObject)
1527
+ if (!derivedTotalAdded) {
1528
+ val readTotalCaloriesResult = addWorkoutMetric(workout, workoutObject, getMetricAndMapper("total-calories"))
1529
+ if(!readTotalCaloriesResult) {
1530
+ addWorkoutMetric(workout, workoutObject, getMetricAndMapper("active-calories"))
1531
+ }
1415
1532
  }
1416
1533
 
1417
1534
  addWorkoutMetric(workout, workoutObject, getMetricAndMapper("distance"))
@@ -1451,6 +1568,27 @@ class HealthPlugin : Plugin() {
1451
1568
  }
1452
1569
  }
1453
1570
 
1571
+ private suspend fun addWorkoutTotalCalories(
1572
+ workout: ExerciseSessionRecord,
1573
+ jsWorkout: JSObject
1574
+ ): Boolean {
1575
+ return try {
1576
+ val totalCalories = sumActiveAndBasalCalories(
1577
+ TimeRangeFilter.between(workout.startTime, workout.endTime),
1578
+ includeTotalMetricFallback = true
1579
+ )
1580
+ if (totalCalories != null) {
1581
+ jsWorkout.put("calories", totalCalories)
1582
+ true
1583
+ } else {
1584
+ false
1585
+ }
1586
+ } catch (e: Exception) {
1587
+ Log.e(tag, "addWorkoutTotalCalories: Failed to derive calories", e)
1588
+ false
1589
+ }
1590
+ }
1591
+
1454
1592
  private suspend fun addWorkoutMetric(
1455
1593
  workout: ExerciseSessionRecord,
1456
1594
  jsWorkout: JSObject,
@@ -161,26 +161,85 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
161
161
  call.reject("Sleep type not available")
162
162
  return
163
163
  }
164
-
165
- let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
166
- let predicate = HKQuery.predicateForSamples(withStart: Date.distantPast, end: Date(), options: .strictEndDate)
167
-
168
- let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: 1, sortDescriptors: [sortDescriptor]) { _, samples, error in
169
- guard let sleepSample = samples?.first as? HKCategorySample else {
170
- if let error = error {
171
- call.reject("Error fetching latest sleep sample", "NO_SAMPLE", error)
172
- } else {
173
- call.reject("No sleep sample found", "NO_SAMPLE")
164
+
165
+ let endDate = Date()
166
+ let startDate = Calendar.current.date(byAdding: .hour, value: -36, to: endDate) ?? endDate.addingTimeInterval(-36 * 3600)
167
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictEndDate)
168
+
169
+ let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
170
+ if let error = error {
171
+ call.reject("Error fetching latest sleep sample", "NO_SAMPLE", error)
172
+ return
173
+ }
174
+ guard let categorySamples = samples as? [HKCategorySample], !categorySamples.isEmpty else {
175
+ call.reject("No sleep sample found", "NO_SAMPLE")
176
+ return
177
+ }
178
+
179
+ let asleepValues: Set<Int> = {
180
+ var values: [Int] = [HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue]
181
+ if #available(iOS 16.0, *) {
182
+ values.append(contentsOf: [
183
+ HKCategoryValueSleepAnalysis.asleepCore.rawValue,
184
+ HKCategoryValueSleepAnalysis.asleepDeep.rawValue,
185
+ HKCategoryValueSleepAnalysis.asleepREM.rawValue
186
+ ])
187
+ }
188
+ return Set(values)
189
+ }()
190
+
191
+ func isAsleep(_ value: Int) -> Bool {
192
+ if asleepValues.contains(value) {
193
+ return true
174
194
  }
195
+ // Fallback: treat any non in-bed/awake as asleep
196
+ return value != HKCategoryValueSleepAnalysis.inBed.rawValue &&
197
+ value != HKCategoryValueSleepAnalysis.awake.rawValue
198
+ }
199
+
200
+ let asleepSamples = categorySamples
201
+ .filter { isAsleep($0.value) }
202
+ .sorted { $0.startDate < $1.startDate }
203
+
204
+ guard !asleepSamples.isEmpty else {
205
+ call.reject("No sleep sample found", "NO_SAMPLE")
175
206
  return
176
207
  }
177
- let durationMinutes = sleepSample.endDate.timeIntervalSince(sleepSample.startDate) / 60
208
+
209
+ let maxGap: TimeInterval = 90 * 60 // 90 minutes separates sessions
210
+ var sessions: [(start: Date, end: Date, duration: TimeInterval)] = []
211
+ var currentStart: Date?
212
+ var currentEnd: Date?
213
+ var currentDuration: TimeInterval = 0
214
+
215
+ for sample in asleepSamples {
216
+ if let lastEnd = currentEnd, sample.startDate.timeIntervalSince(lastEnd) > maxGap {
217
+ sessions.append((start: currentStart ?? lastEnd, end: lastEnd, duration: currentDuration))
218
+ currentStart = nil
219
+ currentEnd = nil
220
+ currentDuration = 0
221
+ }
222
+ if currentStart == nil { currentStart = sample.startDate }
223
+ currentEnd = sample.endDate
224
+ currentDuration += sample.endDate.timeIntervalSince(sample.startDate)
225
+ }
226
+ if let start = currentStart, let end = currentEnd {
227
+ sessions.append((start: start, end: end, duration: currentDuration))
228
+ }
229
+
230
+ guard !sessions.isEmpty else {
231
+ call.reject("No sleep sample found", "NO_SAMPLE")
232
+ return
233
+ }
234
+
235
+ let minSessionDuration: TimeInterval = 3 * 3600 // prefer sessions 3h+
236
+ let preferredSession = sessions.reversed().first { $0.duration >= minSessionDuration } ?? sessions.last!
237
+
178
238
  call.resolve([
179
- "value": durationMinutes,
180
- "timestamp": sleepSample.startDate.timeIntervalSince1970 * 1000,
181
- "endTimestamp": sleepSample.endDate.timeIntervalSince1970 * 1000,
182
- "unit": "min",
183
- "metadata": ["state": sleepSample.value]
239
+ "value": preferredSession.duration / 60,
240
+ "timestamp": preferredSession.start.timeIntervalSince1970 * 1000,
241
+ "endTimestamp": preferredSession.end.timeIntervalSince1970 * 1000,
242
+ "unit": "min"
184
243
  ])
185
244
  }
186
245
  healthStore.execute(query)
@@ -725,8 +784,9 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
725
784
  let calendar = Calendar.current
726
785
  if let categorySamples = samples as? [HKCategorySample], error == nil {
727
786
  for sample in categorySamples {
728
- // Ignore in-bed samples; we care about actual sleep
787
+ // Ignore in-bed and awake samples; we care about actual sleep
729
788
  if sample.value == HKCategoryValueSleepAnalysis.inBed.rawValue { continue }
789
+ if sample.value == HKCategoryValueSleepAnalysis.awake.rawValue { continue }
730
790
  let startOfDay = calendar.startOfDay(for: sample.startDate)
731
791
  let duration = sample.endDate.timeIntervalSince(sample.startDate)
732
792
  dailyDurations[startOfDay, default: 0] += duration
@@ -821,52 +881,55 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
821
881
  healthStore.execute(query)
822
882
  }
823
883
 
884
+ private func sumEnergy(
885
+ _ identifier: HKQuantityTypeIdentifier,
886
+ startDate: Date,
887
+ endDate: Date,
888
+ completion: @escaping (Double?, Error?) -> Void
889
+ ) {
890
+ guard let type = HKObjectType.quantityType(forIdentifier: identifier) else {
891
+ completion(nil, NSError(domain: "HealthKit", code: -1, userInfo: [NSLocalizedDescriptionKey: "Quantity type unavailable"]))
892
+ return
893
+ }
894
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
895
+ let query = HKStatisticsQuery(
896
+ quantityType: type,
897
+ quantitySamplePredicate: predicate,
898
+ options: .cumulativeSum
899
+ ) { _, result, error in
900
+ if let error = error {
901
+ completion(nil, error)
902
+ return
903
+ }
904
+ let value = result?.sumQuantity()?.doubleValue(for: HKUnit.kilocalorie())
905
+ completion(value, nil)
906
+ }
907
+ healthStore.execute(query)
908
+ }
909
+
824
910
  private func queryLatestTotalCalories(_ call: CAPPluginCall) {
825
911
  let unit = HKUnit.kilocalorie()
826
912
  let group = DispatchGroup()
913
+ let now = Date()
914
+ let startOfDay = Calendar.current.startOfDay(for: now)
827
915
 
828
- var activeValue: Double?
829
- var basalValue: Double?
830
- var activeDate: Date?
831
- var basalDate: Date?
916
+ var activeTotal: Double?
917
+ var basalTotal: Double?
832
918
  var queryError: Error?
833
919
  let basalSupported = HKObjectType.quantityType(forIdentifier: .basalEnergyBurned) != nil
834
920
 
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
921
  group.enter()
857
- fetchLatest(.activeEnergyBurned) { value, date, error in
858
- activeValue = value
859
- activeDate = date
860
- queryError = queryError ?? error
922
+ sumEnergy(.activeEnergyBurned, startDate: startOfDay, endDate: now) { value, error in
923
+ activeTotal = value
924
+ if queryError == nil { queryError = error }
861
925
  group.leave()
862
926
  }
863
927
 
864
928
  if basalSupported {
865
929
  group.enter()
866
- fetchLatest(.basalEnergyBurned) { value, date, error in
867
- basalValue = value
868
- basalDate = date
869
- queryError = queryError ?? error
930
+ sumEnergy(.basalEnergyBurned, startDate: startOfDay, endDate: now) { value, error in
931
+ basalTotal = value
932
+ if queryError == nil { queryError = error }
870
933
  group.leave()
871
934
  }
872
935
  }
@@ -876,15 +939,14 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
876
939
  call.reject("Error fetching total calories: \(error.localizedDescription)")
877
940
  return
878
941
  }
879
- guard activeValue != nil || basalValue != nil else {
942
+ guard activeTotal != nil || basalTotal != nil else {
880
943
  call.reject("No sample found", "NO_SAMPLE")
881
944
  return
882
945
  }
883
- let total = (activeValue ?? 0) + (basalValue ?? 0)
884
- let timestamp = max(activeDate?.timeIntervalSince1970 ?? 0, basalDate?.timeIntervalSince1970 ?? 0) * 1000
946
+ let total = (activeTotal ?? 0) + (basalTotal ?? 0)
885
947
  call.resolve([
886
948
  "value": total,
887
- "timestamp": timestamp,
949
+ "timestamp": now.timeIntervalSince1970 * 1000,
888
950
  "unit": unit.unitString
889
951
  ])
890
952
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flomentumsolutions/capacitor-health-extended",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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",