@flomentumsolutions/capacitor-health-extended 0.6.3 → 0.7.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
@@ -27,7 +27,8 @@ Thanks [@mley](https://github.com/mley) for the ground work. The goal of this fo
27
27
  - Request and verify health permissions
28
28
  - Query aggregated data like steps or calories
29
29
  - Retrieve workout sessions with optional route and heart rate data
30
- - Fetch the latest samples for steps, distance (incl. cycling), calories (active/total/basal), heart‑rate, resting HR, HRV, respiratory rate, blood pressure, oxygen saturation, blood glucose, body temperature (basal + core), body fat, height, weight, flights climbed, sleep, and exercise time.
30
+ - Create workout sessions (e.g., rock climbing) with totals, optional routes, and heart-rate samples (write APIs)
31
+ - Fetch the latest samples for steps, distance (incl. cycling), calories (active/total/basal), heart‑rate, resting HR, HRV, respiratory rate, blood pressure, oxygen saturation, blood glucose, body temperature (basal + core), body fat, height, weight, flights climbed, sleep (incl. REM duration), and exercise time.
31
32
  - Read profile characteristics on iOS: biological sex, blood type, date of birth, Fitzpatrick skin type, wheelchair use.
32
33
 
33
34
  ### Supported data types (parity iOS + Android)
@@ -36,7 +37,7 @@ Thanks [@mley](https://github.com/mley) for the ground work. The goal of this fo
36
37
  - Vitals: heart rate, resting heart rate, HRV, respiratory rate, blood pressure, oxygen saturation, blood glucose, body temperature, basal body temperature
37
38
  - Body: weight, height, body fat
38
39
  - Characteristics (iOS): biological sex, blood type, date of birth, Fitzpatrick skin type, wheelchair use
39
- - Sessions: mindfulness, sleep
40
+ - Sessions: mindfulness, sleep, sleep REM (latest sample only)
40
41
 
41
42
  ## Install
42
43
 
@@ -57,6 +58,7 @@ you can keep using the CocoaPods spec `FlomentumSolutionsCapacitorHealthExtended
57
58
  * Make sure your app id has the 'HealthKit' entitlement when this plugin is installed (see iOS dev center).
58
59
  * Also, make sure your app and App Store description comply with the Apple review guidelines.
59
60
  * There are two keys to be added to the info.plist file: NSHealthShareUsageDescription and NSHealthUpdateUsageDescription.
61
+ * Request WRITE_* permissions with `requestHealthPermissions` to enable saving workouts/energy/distance/routes/heart-rate samples to HealthKit.
60
62
 
61
63
  ### Android
62
64
 
@@ -90,8 +92,16 @@ you can keep using the CocoaPods spec `FlomentumSolutionsCapacitorHealthExtended
90
92
  <uses-permission android:name="android.permission.health.READ_FLOORS_CLIMBED" />
91
93
  <uses-permission android:name="android.permission.health.READ_BASAL_METABOLIC_RATE" />
92
94
  <uses-permission android:name="android.permission.health.READ_SLEEP" />
95
+ <uses-permission android:name="android.permission.health.WRITE_EXERCISE" />
96
+ <uses-permission android:name="android.permission.health.WRITE_ACTIVE_CALORIES_BURNED" />
97
+ <uses-permission android:name="android.permission.health.WRITE_TOTAL_CALORIES_BURNED" />
98
+ <uses-permission android:name="android.permission.health.WRITE_DISTANCE" />
99
+ <uses-permission android:name="android.permission.health.WRITE_HEART_RATE" />
100
+ <uses-permission android:name="android.permission.health.WRITE_EXERCISE_ROUTE" />
93
101
  ```
94
102
 
103
+ Include the WRITE_* entries when you call `saveWorkout` to insert exercise sessions, energy, distance, routes, or heart rate samples.
104
+
95
105
  * Android Manifest in application tag
96
106
  ```xml
97
107
  <!-- Handle Health Connect rationale (Android 13-) -->
@@ -223,6 +233,7 @@ This setup ensures your WebView will load HTTPS content securely and complies wi
223
233
  * [`queryHeight()`](#queryheight)
224
234
  * [`queryHeartRate()`](#queryheartrate)
225
235
  * [`querySteps()`](#querysteps)
236
+ * [`saveWorkout(...)`](#saveworkout)
226
237
  * [Interfaces](#interfaces)
227
238
  * [Type Aliases](#type-aliases)
228
239
 
@@ -384,6 +395,7 @@ queryLatestSample(request: { dataType: LatestDataType; }) => Promise<QueryLatest
384
395
 
385
396
  Query latest sample for a specific data type
386
397
  - 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.
398
+ - `sleep-rem` returns REM duration (minutes) for the latest sleep session; requires iOS 16+ sleep stages and Health Connect REM data on Android.
387
399
 
388
400
  | Param | Type |
389
401
  | ------------- | ------------------------------------------------------------------------ |
@@ -446,6 +458,26 @@ Query latest steps sample
446
458
  --------------------
447
459
 
448
460
 
461
+ ### saveWorkout(...)
462
+
463
+ ```typescript
464
+ saveWorkout(request: SaveWorkoutRequest) => Promise<SaveWorkoutResponse>
465
+ ```
466
+
467
+ Create a workout session with optional totals and route/heart-rate samples.
468
+ - iOS stores an `HKWorkout` (activityType mapped from `activityType`) with total energy/distance and optional metadata/route/heart-rate samples.
469
+ - Android stores an `ExerciseSessionRecord` plus `ActiveCaloriesBurnedRecord`, `DistanceRecord`, and `HeartRateRecord` when provided. Routes are attached via `ExerciseRoute`.
470
+ - Requires matching WRITE_* permissions for the values you include (e.g., WRITE_WORKOUTS + WRITE_ACTIVE_CALORIES + WRITE_DISTANCE + WRITE_HEART_RATE + WRITE_ROUTE).
471
+
472
+ | Param | Type |
473
+ | ------------- | ----------------------------------------------------------------- |
474
+ | **`request`** | <code><a href="#saveworkoutrequest">SaveWorkoutRequest</a></code> |
475
+
476
+ **Returns:** <code>Promise&lt;<a href="#saveworkoutresponse">SaveWorkoutResponse</a>&gt;</code>
477
+
478
+ --------------------
479
+
480
+
449
481
  ### Interfaces
450
482
 
451
483
 
@@ -572,6 +604,28 @@ Query latest steps sample
572
604
  | **`metadata`** | <code><a href="#record">Record</a>&lt;string, unknown&gt;</code> |
573
605
 
574
606
 
607
+ #### SaveWorkoutResponse
608
+
609
+ | Prop | Type |
610
+ | ------------- | -------------------- |
611
+ | **`success`** | <code>boolean</code> |
612
+ | **`id`** | <code>string</code> |
613
+
614
+
615
+ #### SaveWorkoutRequest
616
+
617
+ | Prop | Type |
618
+ | ---------------------- | ------------------------------------------------------------------- |
619
+ | **`activityType`** | <code><a href="#workoutactivitytype">WorkoutActivityType</a></code> |
620
+ | **`startDate`** | <code>string</code> |
621
+ | **`endDate`** | <code>string</code> |
622
+ | **`calories`** | <code>number</code> |
623
+ | **`distance`** | <code>number</code> |
624
+ | **`metadata`** | <code><a href="#record">Record</a>&lt;string, any&gt;</code> |
625
+ | **`route`** | <code>RouteSample[]</code> |
626
+ | **`heartRateSamples`** | <code>HeartRateSample[]</code> |
627
+
628
+
575
629
  ### Type Aliases
576
630
 
577
631
 
@@ -584,7 +638,7 @@ Construct a type with a set of properties K of type T
584
638
 
585
639
  #### HealthPermission
586
640
 
587
- <code>'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' | 'READ_BIOLOGICAL_SEX' | 'READ_BLOOD_TYPE' | 'READ_DATE_OF_BIRTH' | 'READ_FITZPATRICK_SKIN_TYPE' | 'READ_WHEELCHAIR_USE'</code>
641
+ <code>'READ_STEPS' | 'READ_WORKOUTS' | 'WRITE_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'WRITE_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'WRITE_TOTAL_CALORIES' | 'READ_DISTANCE' | 'WRITE_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'WRITE_HEART_RATE' | 'READ_RESTING_HEART_RATE' | 'READ_ROUTE' | 'WRITE_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' | 'READ_BIOLOGICAL_SEX' | 'READ_BLOOD_TYPE' | 'READ_DATE_OF_BIRTH' | 'READ_FITZPATRICK_SKIN_TYPE' | 'READ_WHEELCHAIR_USE'</code>
588
642
 
589
643
 
590
644
  #### HealthBiologicalSex
@@ -609,6 +663,11 @@ Construct a type with a set of properties K of type T
609
663
 
610
664
  #### LatestDataType
611
665
 
612
- <code>'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'</code>
666
+ <code>'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' | 'sleep-rem' | 'hrv' | 'blood-pressure'</code>
667
+
668
+
669
+ #### WorkoutActivityType
670
+
671
+ <code>'rock-climbing' | 'climbing' | 'hiking' | 'running' | 'walking' | 'cycling' | 'biking' | 'strength-training' | 'yoga' | 'other'</code>
613
672
 
614
673
  </docgen-api>
@@ -10,11 +10,14 @@ import androidx.health.connect.client.aggregate.AggregateMetric
10
10
  import androidx.health.connect.client.aggregate.AggregationResult
11
11
  import androidx.health.connect.client.aggregate.AggregationResultGroupedByPeriod
12
12
  import androidx.health.connect.client.records.*
13
+ import androidx.health.connect.client.records.metadata.Metadata
13
14
  import androidx.health.connect.client.request.AggregateGroupByPeriodRequest
14
15
  import androidx.health.connect.client.request.AggregateRequest
15
16
  import androidx.health.connect.client.request.ReadRecordsRequest
16
17
  import androidx.health.connect.client.time.TimeRangeFilter
17
18
  import androidx.health.connect.client.records.Record
19
+ import androidx.health.connect.client.units.kilocalories
20
+ import androidx.health.connect.client.units.meters
18
21
  import com.getcapacitor.JSArray
19
22
  import com.getcapacitor.JSObject
20
23
  import com.getcapacitor.Plugin
@@ -57,7 +60,13 @@ enum class CapHealthPermission {
57
60
  READ_BODY_FAT,
58
61
  READ_FLOORS_CLIMBED,
59
62
  READ_BASAL_CALORIES,
60
- READ_SLEEP;
63
+ READ_SLEEP,
64
+ WRITE_WORKOUTS,
65
+ WRITE_ACTIVE_CALORIES,
66
+ WRITE_TOTAL_CALORIES,
67
+ WRITE_DISTANCE,
68
+ WRITE_HEART_RATE,
69
+ WRITE_ROUTE;
61
70
 
62
71
  companion object {
63
72
  fun from(s: String): CapHealthPermission? {
@@ -95,7 +104,13 @@ enum class CapHealthPermission {
95
104
  Permission(alias = "READ_BODY_FAT", strings = ["android.permission.health.READ_BODY_FAT"]),
96
105
  Permission(alias = "READ_FLOORS_CLIMBED", strings = ["android.permission.health.READ_FLOORS_CLIMBED"]),
97
106
  Permission(alias = "READ_BASAL_CALORIES", strings = ["android.permission.health.READ_BASAL_METABOLIC_RATE"]),
98
- Permission(alias = "READ_SLEEP", strings = ["android.permission.health.READ_SLEEP"])
107
+ Permission(alias = "READ_SLEEP", strings = ["android.permission.health.READ_SLEEP"]),
108
+ Permission(alias = "WRITE_WORKOUTS", strings = ["android.permission.health.WRITE_EXERCISE"]),
109
+ Permission(alias = "WRITE_ACTIVE_CALORIES", strings = ["android.permission.health.WRITE_ACTIVE_CALORIES_BURNED"]),
110
+ Permission(alias = "WRITE_TOTAL_CALORIES", strings = ["android.permission.health.WRITE_TOTAL_CALORIES_BURNED"]),
111
+ Permission(alias = "WRITE_DISTANCE", strings = ["android.permission.health.WRITE_DISTANCE"]),
112
+ Permission(alias = "WRITE_HEART_RATE", strings = ["android.permission.health.WRITE_HEART_RATE"]),
113
+ Permission(alias = "WRITE_ROUTE", strings = ["android.permission.health.WRITE_EXERCISE_ROUTE"])
99
114
  ]
100
115
  )
101
116
 
@@ -132,7 +147,13 @@ class HealthPlugin : Plugin() {
132
147
  CapHealthPermission.READ_BODY_FAT to HealthPermission.getReadPermission(BodyFatRecord::class),
133
148
  CapHealthPermission.READ_FLOORS_CLIMBED to HealthPermission.getReadPermission(FloorsClimbedRecord::class),
134
149
  CapHealthPermission.READ_BASAL_CALORIES to HealthPermission.getReadPermission(BasalMetabolicRateRecord::class),
135
- CapHealthPermission.READ_SLEEP to HealthPermission.getReadPermission(SleepSessionRecord::class)
150
+ CapHealthPermission.READ_SLEEP to HealthPermission.getReadPermission(SleepSessionRecord::class),
151
+ CapHealthPermission.WRITE_WORKOUTS to HealthPermission.getWritePermission(ExerciseSessionRecord::class),
152
+ CapHealthPermission.WRITE_ACTIVE_CALORIES to HealthPermission.getWritePermission(ActiveCaloriesBurnedRecord::class),
153
+ CapHealthPermission.WRITE_TOTAL_CALORIES to HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class),
154
+ CapHealthPermission.WRITE_DISTANCE to HealthPermission.getWritePermission(DistanceRecord::class),
155
+ CapHealthPermission.WRITE_HEART_RATE to HealthPermission.getWritePermission(HeartRateRecord::class),
156
+ CapHealthPermission.WRITE_ROUTE to HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE
136
157
  )
137
158
 
138
159
  override fun load() {
@@ -405,6 +426,7 @@ class HealthPlugin : Plugin() {
405
426
  "exercise-time" -> readLatestExerciseTime()
406
427
  "mindfulness" -> readLatestMindfulness()
407
428
  "sleep" -> readLatestSleep()
429
+ "sleep-rem" -> readLatestSleepRem()
408
430
  else -> throw IllegalArgumentException("Unsupported data type: $dataType")
409
431
  }
410
432
  call.resolve(result)
@@ -815,6 +837,32 @@ class HealthPlugin : Plugin() {
815
837
  }
816
838
  }
817
839
 
840
+ private suspend fun readLatestSleepRem(): JSObject {
841
+ val hasSleepPermission = hasPermission(CapHealthPermission.READ_SLEEP)
842
+ if (!hasSleepPermission) {
843
+ throw Exception("Permission for sleep not granted")
844
+ }
845
+ val request = ReadRecordsRequest(
846
+ recordType = SleepSessionRecord::class,
847
+ timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
848
+ pageSize = 1
849
+ )
850
+ val record = healthConnectClient.readRecords(request).records.firstOrNull()
851
+ ?: throw Exception("No sleep data found")
852
+ val remMinutes = record.stages
853
+ .filter { it.stage == SleepSessionRecord.STAGE_TYPE_REM }
854
+ .sumOf { it.endTime.epochSecond - it.startTime.epochSecond } / 60.0
855
+ if (remMinutes <= 0) {
856
+ throw Exception("No REM sleep data found")
857
+ }
858
+ return JSObject().apply {
859
+ put("value", remMinutes)
860
+ put("timestamp", record.startTime.epochSecond * 1000)
861
+ put("endTimestamp", record.endTime.epochSecond * 1000)
862
+ put("unit", "min")
863
+ }
864
+ }
865
+
818
866
  private suspend fun sumActiveAndBasalCalories(
819
867
  timeRange: TimeRangeFilter,
820
868
  includeTotalMetricFallback: Boolean = false
@@ -1471,6 +1519,151 @@ class HealthPlugin : Plugin() {
1471
1519
  return granted.contains(targetPermission)
1472
1520
  }
1473
1521
 
1522
+ @PluginMethod
1523
+ fun saveWorkout(call: PluginCall) {
1524
+ if (!ensureClientInitialized(call)) return
1525
+
1526
+ val activityTypeRaw = call.getString("activityType")
1527
+ val startDate = call.getString("startDate")
1528
+ val endDate = call.getString("endDate")
1529
+ val calories = call.getDouble("calories")
1530
+ val distance = call.getDouble("distance")
1531
+ val metadata = call.getObject("metadata")
1532
+ val routeArray = call.getArray("route")
1533
+ val heartRateArray = call.getArray("heartRateSamples")
1534
+
1535
+ if (activityTypeRaw == null || startDate == null || endDate == null) {
1536
+ call.reject("Missing required parameters: activityType, startDate, endDate")
1537
+ return
1538
+ }
1539
+
1540
+ val startInstant = parseInstant(startDate)
1541
+ val endInstant = parseInstant(endDate)
1542
+ if (startInstant == null || endInstant == null) {
1543
+ call.reject("Invalid startDate or endDate; must be ISO-8601 strings.")
1544
+ return
1545
+ }
1546
+ if (!startInstant.isBefore(endInstant)) {
1547
+ call.reject("startDate must be before endDate")
1548
+ return
1549
+ }
1550
+
1551
+ CoroutineScope(Dispatchers.IO).launch {
1552
+ try {
1553
+ val granted = healthConnectClient.permissionController.getGrantedPermissions()
1554
+ fun ensurePermission(cap: CapHealthPermission, name: String): Boolean {
1555
+ val hc = permissionMapping[cap]
1556
+ val ok = hc != null && granted.contains(hc)
1557
+ if (!ok) {
1558
+ call.reject("Missing $name permission")
1559
+ }
1560
+ return ok
1561
+ }
1562
+
1563
+ if (!ensurePermission(CapHealthPermission.WRITE_WORKOUTS, "WRITE_WORKOUTS")) return@launch
1564
+
1565
+ val startZoneOffset = startInstant.atZone(ZoneId.systemDefault()).offset
1566
+ val endZoneOffset = endInstant.atZone(ZoneId.systemDefault()).offset
1567
+
1568
+ val exerciseType = resolveExerciseType(activityTypeRaw)
1569
+ val title = metadata?.optString("title")?.takeIf { it.isNotBlank() }
1570
+ val notes = metadata?.optString("notes")?.takeIf { it.isNotBlank() }
1571
+ ?: metadata?.optString("description")?.takeIf { it.isNotBlank() }
1572
+
1573
+ val route = buildExerciseRoute(routeArray, startInstant, endInstant)
1574
+ if (route != null && !granted.contains(HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE)) {
1575
+ call.reject("Missing WRITE_ROUTE permission")
1576
+ return@launch
1577
+ }
1578
+
1579
+ val records = mutableListOf<Record>()
1580
+ records.add(
1581
+ ExerciseSessionRecord(
1582
+ startTime = startInstant,
1583
+ startZoneOffset = startZoneOffset,
1584
+ endTime = endInstant,
1585
+ endZoneOffset = endZoneOffset,
1586
+ metadata = Metadata.manualEntry(),
1587
+ exerciseType = exerciseType,
1588
+ title = title,
1589
+ notes = notes,
1590
+ exerciseRoute = route
1591
+ )
1592
+ )
1593
+
1594
+ if (calories != null) {
1595
+ when {
1596
+ granted.contains(permissionMapping[CapHealthPermission.WRITE_ACTIVE_CALORIES]) -> {
1597
+ records.add(
1598
+ ActiveCaloriesBurnedRecord(
1599
+ startTime = startInstant,
1600
+ startZoneOffset = startZoneOffset,
1601
+ endTime = endInstant,
1602
+ endZoneOffset = endZoneOffset,
1603
+ energy = kilocalories(calories),
1604
+ metadata = Metadata.manualEntry()
1605
+ )
1606
+ )
1607
+ }
1608
+ granted.contains(permissionMapping[CapHealthPermission.WRITE_TOTAL_CALORIES]) -> {
1609
+ records.add(
1610
+ TotalCaloriesBurnedRecord(
1611
+ startTime = startInstant,
1612
+ startZoneOffset = startZoneOffset,
1613
+ endTime = endInstant,
1614
+ endZoneOffset = endZoneOffset,
1615
+ energy = kilocalories(calories),
1616
+ metadata = Metadata.manualEntry()
1617
+ )
1618
+ )
1619
+ }
1620
+ else -> {
1621
+ call.reject("Missing WRITE_ACTIVE_CALORIES or WRITE_TOTAL_CALORIES permission")
1622
+ return@launch
1623
+ }
1624
+ }
1625
+ }
1626
+
1627
+ if (distance != null) {
1628
+ if (!ensurePermission(CapHealthPermission.WRITE_DISTANCE, "WRITE_DISTANCE")) return@launch
1629
+ records.add(
1630
+ DistanceRecord(
1631
+ startTime = startInstant,
1632
+ startZoneOffset = startZoneOffset,
1633
+ endTime = endInstant,
1634
+ endZoneOffset = endZoneOffset,
1635
+ distance = meters(distance),
1636
+ metadata = Metadata.manualEntry()
1637
+ )
1638
+ )
1639
+ }
1640
+
1641
+ val heartRateSamples = buildHeartRateSamples(heartRateArray, startInstant, endInstant)
1642
+ if (heartRateSamples.isNotEmpty()) {
1643
+ if (!ensurePermission(CapHealthPermission.WRITE_HEART_RATE, "WRITE_HEART_RATE")) return@launch
1644
+ records.add(
1645
+ HeartRateRecord(
1646
+ startTime = heartRateSamples.first().time,
1647
+ startZoneOffset = heartRateSamples.first().time.atZone(ZoneId.systemDefault()).offset,
1648
+ endTime = heartRateSamples.last().time,
1649
+ endZoneOffset = heartRateSamples.last().time.atZone(ZoneId.systemDefault()).offset,
1650
+ samples = heartRateSamples,
1651
+ metadata = Metadata.manualEntry()
1652
+ )
1653
+ )
1654
+ }
1655
+
1656
+ val response = healthConnectClient.insertRecords(records)
1657
+ val result = JSObject()
1658
+ result.put("success", true)
1659
+ response.recordIdsList.firstOrNull()?.let { result.put("id", it) }
1660
+ call.resolve(result)
1661
+ } catch (e: Exception) {
1662
+ call.reject("Failed to save workout: ${e.message}")
1663
+ }
1664
+ }
1665
+ }
1666
+
1474
1667
  @PluginMethod
1475
1668
  fun queryWorkouts(call: PluginCall) {
1476
1669
  if (!ensureClientInitialized(call)) return
@@ -1662,6 +1855,71 @@ class HealthPlugin : Plugin() {
1662
1855
  return routeArray
1663
1856
  }
1664
1857
 
1858
+ private fun parseInstant(value: Any?): Instant? {
1859
+ return when (value) {
1860
+ is String -> {
1861
+ try {
1862
+ Instant.parse(value)
1863
+ } catch (_: Exception) {
1864
+ value.toDoubleOrNull()?.let { Instant.ofEpochMilli(it.toLong()) }
1865
+ }
1866
+ }
1867
+ is Number -> Instant.ofEpochMilli(value.toLong())
1868
+ else -> null
1869
+ }
1870
+ }
1871
+
1872
+ private fun resolveExerciseType(activityTypeRaw: String): Int {
1873
+ val normalized = activityTypeRaw.lowercase().replace("-", "_")
1874
+ return ExerciseSessionRecord.EXERCISE_TYPE_STRING_TO_INT_MAP[normalized]
1875
+ ?: exerciseTypeMapping.entries.firstOrNull { it.value.equals(activityTypeRaw, ignoreCase = true) }?.key?.toInt()
1876
+ ?: ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
1877
+ }
1878
+
1879
+ private fun buildExerciseRoute(routeArray: JSArray?, startTime: Instant, endTime: Instant): ExerciseRoute? {
1880
+ val locations = routeArray
1881
+ ?.toList<JSObject>()
1882
+ ?.mapNotNull { point ->
1883
+ val lat = point.optDouble("lat", Double.NaN)
1884
+ val lng = point.optDouble("lng", Double.NaN)
1885
+ val ts = parseInstant(point.opt("timestamp")) ?: return@mapNotNull null
1886
+ if (lat.isNaN() || lng.isNaN()) return@mapNotNull null
1887
+ if (ts.isBefore(startTime) || ts.isAfter(endTime)) return@mapNotNull null
1888
+ val altitudeValue = point.optDouble("alt", Double.NaN)
1889
+ ExerciseRoute.Location(
1890
+ time = ts,
1891
+ latitude = lat,
1892
+ longitude = lng,
1893
+ altitude = if (altitudeValue.isNaN()) null else meters(altitudeValue)
1894
+ )
1895
+ }
1896
+ ?.sortedBy { it.time }
1897
+ ?.toList()
1898
+
1899
+ if (locations.isNullOrEmpty()) {
1900
+ return null
1901
+ }
1902
+ return ExerciseRoute(locations)
1903
+ }
1904
+
1905
+ private fun buildHeartRateSamples(array: JSArray?, startTime: Instant, endTime: Instant): List<HeartRateRecord.Sample> {
1906
+ return array
1907
+ ?.toList<JSObject>()
1908
+ ?.mapNotNull { point ->
1909
+ val bpm = point.optDouble("bpm", Double.NaN)
1910
+ val ts = parseInstant(point.opt("timestamp")) ?: return@mapNotNull null
1911
+ if (bpm.isNaN()) return@mapNotNull null
1912
+ val clampedBpm = bpm.toLong().coerceIn(1, 300)
1913
+ HeartRateRecord.Sample(
1914
+ time = ts,
1915
+ beatsPerMinute = clampedBpm
1916
+ )
1917
+ }
1918
+ ?.filter { !it.time.isBefore(startTime) && !it.time.isAfter(endTime) }
1919
+ ?.sortedBy { it.time }
1920
+ ?: emptyList()
1921
+ }
1922
+
1665
1923
 
1666
1924
  private val exerciseTypeMapping = mapOf(
1667
1925
  0 to "OTHER",
@@ -64,6 +64,7 @@ export interface HealthPlugin {
64
64
  /**
65
65
  * Query latest sample for a specific data type
66
66
  * - 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.
67
+ * - `sleep-rem` returns REM duration (minutes) for the latest sleep session; requires iOS 16+ sleep stages and Health Connect REM data on Android.
67
68
  * @param request
68
69
  */
69
70
  queryLatestSample(request: {
@@ -85,9 +86,16 @@ export interface HealthPlugin {
85
86
  * Query latest steps sample
86
87
  */
87
88
  querySteps(): Promise<QueryLatestSampleResponse>;
89
+ /**
90
+ * Create a workout session with optional totals and route/heart-rate samples.
91
+ * - iOS stores an `HKWorkout` (activityType mapped from `activityType`) with total energy/distance and optional metadata/route/heart-rate samples.
92
+ * - Android stores an `ExerciseSessionRecord` plus `ActiveCaloriesBurnedRecord`, `DistanceRecord`, and `HeartRateRecord` when provided. Routes are attached via `ExerciseRoute`.
93
+ * - Requires matching WRITE_* permissions for the values you include (e.g., WRITE_WORKOUTS + WRITE_ACTIVE_CALORIES + WRITE_DISTANCE + WRITE_HEART_RATE + WRITE_ROUTE).
94
+ */
95
+ saveWorkout(request: SaveWorkoutRequest): Promise<SaveWorkoutResponse>;
88
96
  }
89
- 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' | 'READ_BIOLOGICAL_SEX' | 'READ_BLOOD_TYPE' | 'READ_DATE_OF_BIRTH' | 'READ_FITZPATRICK_SKIN_TYPE' | 'READ_WHEELCHAIR_USE';
90
- 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';
97
+ export declare type HealthPermission = 'READ_STEPS' | 'READ_WORKOUTS' | 'WRITE_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'WRITE_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'WRITE_TOTAL_CALORIES' | 'READ_DISTANCE' | 'WRITE_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'WRITE_HEART_RATE' | 'READ_RESTING_HEART_RATE' | 'READ_ROUTE' | 'WRITE_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' | 'READ_BIOLOGICAL_SEX' | 'READ_BLOOD_TYPE' | 'READ_DATE_OF_BIRTH' | 'READ_FITZPATRICK_SKIN_TYPE' | 'READ_WHEELCHAIR_USE';
98
+ 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' | 'sleep-rem' | 'hrv' | 'blood-pressure';
91
99
  export interface PermissionsRequest {
92
100
  permissions: HealthPermission[];
93
101
  }
@@ -114,6 +122,21 @@ export interface RouteSample {
114
122
  export interface QueryWorkoutResponse {
115
123
  workouts: Workout[];
116
124
  }
125
+ export type WorkoutActivityType = 'rock-climbing' | 'climbing' | 'hiking' | 'running' | 'walking' | 'cycling' | 'biking' | 'strength-training' | 'yoga' | 'other';
126
+ export interface SaveWorkoutRequest {
127
+ activityType: WorkoutActivityType;
128
+ startDate: string;
129
+ endDate: string;
130
+ calories?: number;
131
+ distance?: number;
132
+ metadata?: Record<string, any>;
133
+ route?: RouteSample[];
134
+ heartRateSamples?: HeartRateSample[];
135
+ }
136
+ export interface SaveWorkoutResponse {
137
+ success: boolean;
138
+ id?: string;
139
+ }
117
140
  export interface Workout {
118
141
  startDate: string;
119
142
  endDate: string;
@@ -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 * iOS only: Reads user characteristics such as biological sex, blood type, date of birth, Fitzpatrick skin type, and wheelchair use.\n * Values are null when unavailable or permission was not granted. Android does not expose these characteristics; it returns `platformSupported: false` and a `platformMessage` for UI hints without emitting null values.\n */\n getCharacteristics(): Promise<CharacteristicsResponse>;\n\n /**\n * Query aggregated data\n * - Blood-pressure aggregates return the systolic average in `value` plus `systolic`, `diastolic`, and `unit`.\n * - `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.\n * - Weight/height aggregation returns the latest sample per day (no averaging).\n * - Android aggregation currently supports daily buckets; unsupported buckets will be rejected.\n * - Android `distance-cycling` aggregates distance recorded during biking exercise sessions (requires distance + workouts permissions).\n * - Daily `bucket: \"day\"` queries use calendar-day boundaries in the device time zone (start-of-day through the next start-of-day) instead of a trailing 24-hour window. For “today,” send `startDate` at today’s start-of-day and `endDate` at now or tomorrow’s start-of-day.\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 * - 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.\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 | 'READ_BIOLOGICAL_SEX'\n | 'READ_BLOOD_TYPE'\n | 'READ_DATE_OF_BIRTH'\n | 'READ_FITZPATRICK_SKIN_TYPE'\n | 'READ_WHEELCHAIR_USE';\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\nexport interface CharacteristicsResponse {\n biologicalSex?: HealthBiologicalSex | null;\n bloodType?: HealthBloodType | null;\n dateOfBirth?: string | null;\n fitzpatrickSkinType?: HealthFitzpatrickSkinType | null;\n wheelchairUse?: HealthWheelchairUse | null;\n /**\n * Indicates whether the platform exposes these characteristics via the plugin (true on iOS, false on Android).\n */\n platformSupported?: boolean;\n /**\n * Optional platform-specific message; on Android we return a user-facing note explaining that values remain empty unless synced from iOS.\n */\n platformMessage?: string;\n}\n\nexport type HealthBiologicalSex = 'female' | 'male' | 'other' | 'not_set' | 'unknown';\n\nexport type HealthBloodType =\n | 'a-positive'\n | 'a-negative'\n | 'b-positive'\n | 'b-negative'\n | 'ab-positive'\n | 'ab-negative'\n | 'o-positive'\n | 'o-negative'\n | 'not_set'\n | 'unknown';\n\nexport type HealthFitzpatrickSkinType = 'type1' | 'type2' | 'type3' | 'type4' | 'type5' | 'type6' | 'not_set' | 'unknown';\n\nexport type HealthWheelchairUse = 'wheelchair_user' | 'not_wheelchair_user' | 'not_set' | 'unknown';\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 * iOS only: Reads user characteristics such as biological sex, blood type, date of birth, Fitzpatrick skin type, and wheelchair use.\n * Values are null when unavailable or permission was not granted. Android does not expose these characteristics; it returns `platformSupported: false` and a `platformMessage` for UI hints without emitting null values.\n */\n getCharacteristics(): Promise<CharacteristicsResponse>;\n\n /**\n * Query aggregated data\n * - Blood-pressure aggregates return the systolic average in `value` plus `systolic`, `diastolic`, and `unit`.\n * - `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.\n * - Weight/height aggregation returns the latest sample per day (no averaging).\n * - Android aggregation currently supports daily buckets; unsupported buckets will be rejected.\n * - Android `distance-cycling` aggregates distance recorded during biking exercise sessions (requires distance + workouts permissions).\n * - Daily `bucket: \"day\"` queries use calendar-day boundaries in the device time zone (start-of-day through the next start-of-day) instead of a trailing 24-hour window. For “today,” send `startDate` at today’s start-of-day and `endDate` at now or tomorrow’s start-of-day.\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 * - 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.\n * - `sleep-rem` returns REM duration (minutes) for the latest sleep session; requires iOS 16+ sleep stages and Health Connect REM data on Android.\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 /**\n * Create a workout session with optional totals and route/heart-rate samples.\n * - iOS stores an `HKWorkout` (activityType mapped from `activityType`) with total energy/distance and optional metadata/route/heart-rate samples.\n * - Android stores an `ExerciseSessionRecord` plus `ActiveCaloriesBurnedRecord`, `DistanceRecord`, and `HeartRateRecord` when provided. Routes are attached via `ExerciseRoute`.\n * - Requires matching WRITE_* permissions for the values you include (e.g., WRITE_WORKOUTS + WRITE_ACTIVE_CALORIES + WRITE_DISTANCE + WRITE_HEART_RATE + WRITE_ROUTE).\n */\n saveWorkout(request: SaveWorkoutRequest): Promise<SaveWorkoutResponse>;\n}\n\nexport declare type HealthPermission =\n | 'READ_STEPS'\n | 'READ_WORKOUTS'\n | 'WRITE_WORKOUTS'\n | 'READ_ACTIVE_CALORIES'\n | 'WRITE_ACTIVE_CALORIES'\n | 'READ_TOTAL_CALORIES'\n | 'WRITE_TOTAL_CALORIES'\n | 'READ_DISTANCE'\n | 'WRITE_DISTANCE'\n | 'READ_WEIGHT'\n | 'READ_HEIGHT'\n | 'READ_HEART_RATE'\n | 'WRITE_HEART_RATE'\n | 'READ_RESTING_HEART_RATE'\n | 'READ_ROUTE'\n | 'WRITE_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 | 'READ_BIOLOGICAL_SEX'\n | 'READ_BLOOD_TYPE'\n | 'READ_DATE_OF_BIRTH'\n | 'READ_FITZPATRICK_SKIN_TYPE'\n | 'READ_WHEELCHAIR_USE';\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 | 'sleep-rem'\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 type WorkoutActivityType =\n | 'rock-climbing'\n | 'climbing'\n | 'hiking'\n | 'running'\n | 'walking'\n | 'cycling'\n | 'biking'\n | 'strength-training'\n | 'yoga'\n | 'other';\n\nexport interface SaveWorkoutRequest {\n activityType: WorkoutActivityType;\n startDate: string;\n endDate: string;\n calories?: number;\n distance?: number;\n metadata?: Record<string, any>;\n route?: RouteSample[];\n heartRateSamples?: HeartRateSample[];\n}\n\nexport interface SaveWorkoutResponse {\n success: boolean;\n id?: string;\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\nexport interface CharacteristicsResponse {\n biologicalSex?: HealthBiologicalSex | null;\n bloodType?: HealthBloodType | null;\n dateOfBirth?: string | null;\n fitzpatrickSkinType?: HealthFitzpatrickSkinType | null;\n wheelchairUse?: HealthWheelchairUse | null;\n /**\n * Indicates whether the platform exposes these characteristics via the plugin (true on iOS, false on Android).\n */\n platformSupported?: boolean;\n /**\n * Optional platform-specific message; on Android we return a user-facing note explaining that values remain empty unless synced from iOS.\n */\n platformMessage?: string;\n}\n\nexport type HealthBiologicalSex = 'female' | 'male' | 'other' | 'not_set' | 'unknown';\n\nexport type HealthBloodType =\n | 'a-positive'\n | 'a-negative'\n | 'b-positive'\n | 'b-negative'\n | 'ab-positive'\n | 'ab-negative'\n | 'o-positive'\n | 'o-negative'\n | 'not_set'\n | 'unknown';\n\nexport type HealthFitzpatrickSkinType = 'type1' | 'type2' | 'type3' | 'type4' | 'type5' | 'type6' | 'not_set' | 'unknown';\n\nexport type HealthWheelchairUse = 'wheelchair_user' | 'not_wheelchair_user' | 'not_set' | 'unknown';\n"]}
@@ -2,6 +2,7 @@ import Foundation
2
2
  import UIKit
3
3
  import Capacitor
4
4
  import HealthKit
5
+ import CoreLocation
5
6
 
6
7
  /**
7
8
  * Please read the Capacitor iOS Plugin Development Guide
@@ -19,7 +20,8 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
19
20
  CAPPluginMethod(name: "queryAggregated", returnType: CAPPluginReturnPromise),
20
21
  CAPPluginMethod(name: "queryWorkouts", returnType: CAPPluginReturnPromise),
21
22
  CAPPluginMethod(name: "queryLatestSample", returnType: CAPPluginReturnPromise),
22
- CAPPluginMethod(name: "getCharacteristics", returnType: CAPPluginReturnPromise)
23
+ CAPPluginMethod(name: "getCharacteristics", returnType: CAPPluginReturnPromise),
24
+ CAPPluginMethod(name: "saveWorkout", returnType: CAPPluginReturnPromise)
23
25
  ]
24
26
 
25
27
  let healthStore = HKHealthStore()
@@ -41,7 +43,7 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
41
43
  var result: [String: String] = [:]
42
44
 
43
45
  for permission in permissions {
44
- let hkTypes = permissionToHKObjectType(permission)
46
+ let hkTypes = permissionToHKObjectType(permission) + permissionToHKSampleType(permission)
45
47
  for type in hkTypes {
46
48
  let status = healthStore.authorizationStatus(for: type)
47
49
 
@@ -69,19 +71,20 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
69
71
 
70
72
  print("⚡️ [HealthPlugin] Requesting permissions: \(permissions)")
71
73
 
72
- let types: [HKObjectType] = permissions.flatMap { permissionToHKObjectType($0) }
74
+ let readTypes: [HKObjectType] = permissions.flatMap { permissionToHKObjectType($0) }
75
+ let shareTypes: [HKSampleType] = permissions.flatMap { permissionToHKSampleType($0) }
73
76
 
74
- print("⚡️ [HealthPlugin] Mapped to \(types.count) HKObjectTypes")
77
+ print("⚡️ [HealthPlugin] Mapped to \(readTypes.count) HKObjectTypes (read) and \(shareTypes.count) HKSampleTypes (share)")
75
78
 
76
79
  // Validate that we have at least one valid permission type
77
- guard !types.isEmpty else {
80
+ guard !readTypes.isEmpty || !shareTypes.isEmpty else {
78
81
  let invalidPermissions = permissions.filter { permissionToHKObjectType($0).isEmpty }
79
82
  call.reject("No valid permission types found. Invalid permissions: \(invalidPermissions)")
80
83
  return
81
84
  }
82
85
 
83
86
  DispatchQueue.main.async {
84
- self.healthStore.requestAuthorization(toShare: nil, read: Set(types)) { success, error in
87
+ self.healthStore.requestAuthorization(toShare: Set(shareTypes), read: Set(readTypes)) { success, error in
85
88
  DispatchQueue.main.async {
86
89
  if success {
87
90
  //we don't know which actual permissions were granted, so we assume all
@@ -349,6 +352,113 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
349
352
  healthStore.execute(query)
350
353
  return
351
354
  }
355
+ // ---- Special handling for REM sleep sessions (category samples) ----
356
+ if dataTypeString == "sleep-rem" {
357
+ guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
358
+ call.reject("Sleep type not available")
359
+ return
360
+ }
361
+
362
+ let endDate = Date()
363
+ let startDate = Calendar.current.date(byAdding: .hour, value: -36, to: endDate) ?? endDate.addingTimeInterval(-36 * 3600)
364
+ let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictEndDate)
365
+
366
+ let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
367
+ if let error = error {
368
+ call.reject("Error fetching latest REM sleep sample", "NO_SAMPLE", error)
369
+ return
370
+ }
371
+ guard let categorySamples = samples as? [HKCategorySample], !categorySamples.isEmpty else {
372
+ call.reject("No REM sleep sample found", "NO_SAMPLE")
373
+ return
374
+ }
375
+
376
+ let asleepValues: Set<Int> = {
377
+ if #available(iOS 16.0, *) {
378
+ return Set([
379
+ HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue,
380
+ HKCategoryValueSleepAnalysis.asleepCore.rawValue,
381
+ HKCategoryValueSleepAnalysis.asleepDeep.rawValue,
382
+ HKCategoryValueSleepAnalysis.asleepREM.rawValue
383
+ ])
384
+ } else {
385
+ return Set([HKCategoryValueSleepAnalysis.asleep.rawValue])
386
+ }
387
+ }()
388
+
389
+ let remValue: Int? = {
390
+ if #available(iOS 16.0, *) {
391
+ return HKCategoryValueSleepAnalysis.asleepREM.rawValue
392
+ }
393
+ return nil
394
+ }()
395
+
396
+ func isAsleep(_ value: Int) -> Bool {
397
+ if asleepValues.contains(value) {
398
+ return true
399
+ }
400
+ return value != HKCategoryValueSleepAnalysis.inBed.rawValue &&
401
+ value != HKCategoryValueSleepAnalysis.awake.rawValue
402
+ }
403
+
404
+ let asleepSamples = categorySamples
405
+ .filter { isAsleep($0.value) }
406
+ .sorted { $0.startDate < $1.startDate }
407
+
408
+ guard !asleepSamples.isEmpty else {
409
+ call.reject("No REM sleep sample found", "NO_SAMPLE")
410
+ return
411
+ }
412
+
413
+ let maxGap: TimeInterval = 90 * 60 // 90 minutes separates sessions
414
+ var sessions: [(start: Date, end: Date, duration: TimeInterval, remDuration: TimeInterval)] = []
415
+ var currentStart: Date?
416
+ var currentEnd: Date?
417
+ var currentDuration: TimeInterval = 0
418
+ var currentRemDuration: TimeInterval = 0
419
+
420
+ for sample in asleepSamples {
421
+ if let lastEnd = currentEnd, sample.startDate.timeIntervalSince(lastEnd) > maxGap {
422
+ sessions.append((start: currentStart ?? lastEnd, end: lastEnd, duration: currentDuration, remDuration: currentRemDuration))
423
+ currentStart = nil
424
+ currentEnd = nil
425
+ currentDuration = 0
426
+ currentRemDuration = 0
427
+ }
428
+ if currentStart == nil { currentStart = sample.startDate }
429
+ currentEnd = sample.endDate
430
+ let sampleDuration = sample.endDate.timeIntervalSince(sample.startDate)
431
+ currentDuration += sampleDuration
432
+ if let remValue = remValue, sample.value == remValue {
433
+ currentRemDuration += sampleDuration
434
+ }
435
+ }
436
+ if let start = currentStart, let end = currentEnd {
437
+ sessions.append((start: start, end: end, duration: currentDuration, remDuration: currentRemDuration))
438
+ }
439
+
440
+ guard !sessions.isEmpty else {
441
+ call.reject("No REM sleep sample found", "NO_SAMPLE")
442
+ return
443
+ }
444
+
445
+ let minSessionDuration: TimeInterval = 3 * 3600 // prefer sessions 3h+
446
+ let preferredSession = sessions.reversed().first { $0.duration >= minSessionDuration } ?? sessions.last!
447
+ if preferredSession.remDuration <= 0 {
448
+ call.reject("No REM sleep sample found", "NO_SAMPLE")
449
+ return
450
+ }
451
+
452
+ call.resolve([
453
+ "value": preferredSession.remDuration / 60,
454
+ "timestamp": preferredSession.start.timeIntervalSince1970 * 1000,
455
+ "endTimestamp": preferredSession.end.timeIntervalSince1970 * 1000,
456
+ "unit": "min"
457
+ ])
458
+ }
459
+ healthStore.execute(query)
460
+ return
461
+ }
352
462
  // ---- Special handling for mindfulness sessions (category samples) ----
353
463
  if dataTypeString == "mindfulness" {
354
464
  guard let mindfulType = HKObjectType.categoryType(forIdentifier: .mindfulSession) else {
@@ -527,16 +637,29 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
527
637
  HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
528
638
  HKObjectType.quantityType(forIdentifier: .basalEnergyBurned) // iOS 16+
529
639
  ].compactMap { $0 }
640
+ case "WRITE_TOTAL_CALORIES":
641
+ return [
642
+ HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
643
+ HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
644
+ ].compactMap { $0 }
530
645
  case "READ_ACTIVE_CALORIES":
531
646
  return [HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)].compactMap{$0}
647
+ case "WRITE_ACTIVE_CALORIES":
648
+ return [HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)].compactMap{$0}
532
649
  case "READ_WORKOUTS":
533
650
  return [HKObjectType.workoutType()].compactMap{$0}
651
+ case "WRITE_WORKOUTS":
652
+ return [HKObjectType.workoutType()].compactMap{$0}
534
653
  case "READ_HEART_RATE":
535
654
  return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap{$0}
655
+ case "WRITE_HEART_RATE":
656
+ return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap{$0}
536
657
  case "READ_RESTING_HEART_RATE":
537
658
  return [HKObjectType.quantityType(forIdentifier: .restingHeartRate)].compactMap { $0 }
538
659
  case "READ_ROUTE":
539
660
  return [HKSeriesType.workoutRoute()].compactMap{$0}
661
+ case "WRITE_ROUTE":
662
+ return [HKSeriesType.workoutRoute()].compactMap{$0}
540
663
  case "READ_DISTANCE":
541
664
  return [
542
665
  HKObjectType.quantityType(forIdentifier: .distanceCycling),
@@ -544,6 +667,13 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
544
667
  HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning),
545
668
  HKObjectType.quantityType(forIdentifier: .distanceDownhillSnowSports)
546
669
  ].compactMap{$0}
670
+ case "WRITE_DISTANCE":
671
+ return [
672
+ HKObjectType.quantityType(forIdentifier: .distanceCycling),
673
+ HKObjectType.quantityType(forIdentifier: .distanceSwimming),
674
+ HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning),
675
+ HKObjectType.quantityType(forIdentifier: .distanceDownhillSnowSports)
676
+ ].compactMap{$0}
547
677
  case "READ_MINDFULNESS":
548
678
  return [HKObjectType.categoryType(forIdentifier: .mindfulSession)!].compactMap{$0}
549
679
  case "READ_HRV":
@@ -647,6 +777,34 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
647
777
  }
648
778
  }
649
779
 
780
+ func permissionToHKSampleType(_ permission: String) -> [HKSampleType] {
781
+ switch permission {
782
+ case "WRITE_WORKOUTS":
783
+ return [HKObjectType.workoutType()].compactMap { $0 as? HKSampleType }
784
+ case "WRITE_ACTIVE_CALORIES":
785
+ fallthrough
786
+ case "WRITE_TOTAL_CALORIES":
787
+ return [
788
+ HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
789
+ HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
790
+ ].compactMap { $0 as? HKSampleType }
791
+ case "WRITE_DISTANCE":
792
+ return [
793
+ HKObjectType.quantityType(forIdentifier: .distanceCycling),
794
+ HKObjectType.quantityType(forIdentifier: .distanceSwimming),
795
+ HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning),
796
+ HKObjectType.quantityType(forIdentifier: .distanceDownhillSnowSports)
797
+ ].compactMap { $0 as? HKSampleType }
798
+ case "WRITE_HEART_RATE":
799
+ return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap { $0 as? HKSampleType }
800
+ case "WRITE_ROUTE":
801
+ return [HKSeriesType.workoutRoute()].compactMap { $0 as? HKSampleType }
802
+ default:
803
+ // For convenience, allow read permissions that are sample types to be added to the share set when requested.
804
+ return permissionToHKObjectType(permission).compactMap { $0 as? HKSampleType }
805
+ }
806
+ }
807
+
650
808
  private func mapBiologicalSex(_ biologicalSex: HKBiologicalSex) -> String {
651
809
  switch biologicalSex {
652
810
  case .female:
@@ -1291,7 +1449,223 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
1291
1449
  f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
1292
1450
  return f
1293
1451
  }()
1452
+
1453
+ private func parseDate(_ value: Any?) -> Date? {
1454
+ if let date = value as? Date {
1455
+ return date
1456
+ }
1457
+ if let str = value as? String {
1458
+ if let parsed = isoDateFormatter.date(from: str) {
1459
+ return parsed
1460
+ }
1461
+ if let millis = Double(str) {
1462
+ return Date(timeIntervalSince1970: millis / 1000)
1463
+ }
1464
+ }
1465
+ if let number = value as? NSNumber {
1466
+ return Date(timeIntervalSince1970: number.doubleValue / 1000)
1467
+ }
1468
+ return nil
1469
+ }
1470
+
1471
+ private func workoutActivityType(from value: String) -> HKWorkoutActivityType? {
1472
+ let normalized = value.lowercased()
1473
+ let mapping: [String: UInt] = [
1474
+ "rock-climbing": 9,
1475
+ "climbing": 9,
1476
+ "hiking": 24,
1477
+ "running": 37,
1478
+ "walking": 52,
1479
+ "cycling": 13,
1480
+ "biking": 13,
1481
+ "strength-training": 50,
1482
+ "strength_training": 50,
1483
+ "traditional-strength-training": 50,
1484
+ "yoga": 57,
1485
+ "other": 3000
1486
+ ]
1487
+
1488
+ if let raw = mapping[normalized] {
1489
+ return HKWorkoutActivityType(rawValue: raw)
1490
+ }
1491
+ return HKWorkoutActivityType.other
1492
+ }
1493
+
1494
+ private func sanitizeMetadata(_ metadata: JSObject?) -> [String: Any]? {
1495
+ guard let metadata = metadata else { return nil }
1496
+ var cleaned: [String: Any] = [:]
1497
+ for (key, value) in metadata {
1498
+ switch value {
1499
+ case let number as NSNumber:
1500
+ cleaned[key] = number
1501
+ case let string as NSString:
1502
+ cleaned[key] = string
1503
+ case let date as Date:
1504
+ cleaned[key] = date
1505
+ case let string as String:
1506
+ cleaned[key] = string
1507
+ default:
1508
+ continue
1509
+ }
1510
+ }
1511
+ return cleaned.isEmpty ? nil : cleaned
1512
+ }
1513
+
1514
+ private func buildRouteLocations(from value: Any?, defaultDate: Date) -> [CLLocation] {
1515
+ guard let routeItems = value as? [Any] else { return [] }
1516
+ var locations: [CLLocation] = []
1517
+
1518
+ for case let point as JSObject in routeItems {
1519
+ guard let lat = point["lat"] as? CLLocationDegrees,
1520
+ let lng = point["lng"] as? CLLocationDegrees else { continue }
1521
+ let alt = (point["alt"] as? NSNumber)?.doubleValue ?? 0
1522
+ let timestamp = parseDate(point["timestamp"]) ?? defaultDate
1523
+ let location = CLLocation(
1524
+ coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lng),
1525
+ altitude: alt,
1526
+ horizontalAccuracy: kCLLocationAccuracyBest,
1527
+ verticalAccuracy: kCLLocationAccuracyBest,
1528
+ timestamp: timestamp
1529
+ )
1530
+ locations.append(location)
1531
+ }
1532
+
1533
+ return locations.sorted { $0.timestamp < $1.timestamp }
1534
+ }
1535
+
1536
+ private func buildHeartRateSamples(from value: Any?, defaultStart: Date, defaultEnd: Date) -> [HKQuantitySample] {
1537
+ guard let arr = value as? [Any],
1538
+ let type = HKObjectType.quantityType(forIdentifier: .heartRate) else { return [] }
1539
+
1540
+ let unit = HKUnit.count().unitDivided(by: HKUnit.minute())
1541
+ var samples: [HKQuantitySample] = []
1542
+ for case let point as JSObject in arr {
1543
+ guard let bpm = point["bpm"] as? NSNumber else { continue }
1544
+ let timestamp = parseDate(point["timestamp"]) ?? defaultStart
1545
+ let quantity = HKQuantity(unit: unit, doubleValue: bpm.doubleValue)
1546
+ let sample = HKQuantitySample(type: type, quantity: quantity, start: timestamp, end: timestamp)
1547
+ samples.append(sample)
1548
+ }
1549
+
1550
+ // Ensure samples fall within the workout window to avoid HK errors.
1551
+ return samples
1552
+ .filter { $0.startDate >= defaultStart && $0.endDate <= defaultEnd }
1553
+ .sorted { $0.startDate < $1.startDate }
1554
+ }
1555
+
1294
1556
 
1557
+ @objc func saveWorkout(_ call: CAPPluginCall) {
1558
+ guard HKHealthStore.isHealthDataAvailable() else {
1559
+ call.reject("Health data is unavailable on this device.")
1560
+ return
1561
+ }
1562
+
1563
+ guard let activityTypeString = call.getString("activityType"),
1564
+ let startDateString = call.getString("startDate"),
1565
+ let endDateString = call.getString("endDate"),
1566
+ let startDate = parseDate(startDateString),
1567
+ let endDate = parseDate(endDateString) else {
1568
+ call.reject("Missing or invalid parameters")
1569
+ return
1570
+ }
1571
+
1572
+ guard let activityType = workoutActivityType(from: activityTypeString) else {
1573
+ call.reject("Unsupported activityType: \(activityTypeString)")
1574
+ return
1575
+ }
1576
+
1577
+ guard startDate < endDate else {
1578
+ call.reject("startDate must be before endDate")
1579
+ return
1580
+ }
1581
+
1582
+ let energyQuantity: HKQuantity? = {
1583
+ if let calories = call.getDouble("calories") {
1584
+ return HKQuantity(unit: HKUnit.kilocalorie(), doubleValue: calories)
1585
+ }
1586
+ return nil
1587
+ }()
1588
+ let distanceQuantity: HKQuantity? = {
1589
+ if let distance = call.getDouble("distance") {
1590
+ return HKQuantity(unit: HKUnit.meter(), doubleValue: distance)
1591
+ }
1592
+ return nil
1593
+ }()
1594
+
1595
+ let workout = HKWorkout(
1596
+ activityType: activityType,
1597
+ start: startDate,
1598
+ end: endDate,
1599
+ workoutEvents: nil,
1600
+ totalEnergyBurned: energyQuantity,
1601
+ totalDistance: distanceQuantity,
1602
+ metadata: sanitizeMetadata(call.getObject("metadata"))
1603
+ )
1604
+
1605
+ let routeLocations = buildRouteLocations(from: call.getArray("route"), defaultDate: startDate)
1606
+ let heartRateSamples = buildHeartRateSamples(from: call.getArray("heartRateSamples"), defaultStart: startDate, defaultEnd: endDate)
1607
+
1608
+ healthStore.save(workout) { success, error in
1609
+ DispatchQueue.main.async {
1610
+ if let error = error {
1611
+ call.reject("Failed to save workout: \(error.localizedDescription)")
1612
+ return
1613
+ }
1614
+ guard success else {
1615
+ call.reject("Failed to save workout")
1616
+ return
1617
+ }
1618
+
1619
+ if routeLocations.isEmpty && heartRateSamples.isEmpty {
1620
+ call.resolve(["success": true, "id": workout.uuid.uuidString])
1621
+ return
1622
+ }
1623
+
1624
+ let group = DispatchGroup()
1625
+ var saveError: String?
1626
+
1627
+ if !routeLocations.isEmpty {
1628
+ group.enter()
1629
+ let routeBuilder = HKWorkoutRouteBuilder(healthStore: self.healthStore, device: nil)
1630
+ routeBuilder.insertRouteData(routeLocations) { inserted, insertError in
1631
+ if let insertError = insertError {
1632
+ saveError = "Failed to insert route: \(insertError.localizedDescription)"
1633
+ group.leave()
1634
+ return
1635
+ }
1636
+ routeBuilder.finishRoute(with: workout, metadata: nil) { _, finishError in
1637
+ if let finishError = finishError {
1638
+ saveError = "Failed to finish route: \(finishError.localizedDescription)"
1639
+ } else if !inserted {
1640
+ saveError = "Failed to insert route"
1641
+ }
1642
+ group.leave()
1643
+ }
1644
+ }
1645
+ }
1646
+
1647
+ if !heartRateSamples.isEmpty {
1648
+ group.enter()
1649
+ self.healthStore.add(heartRateSamples, to: workout) { hrSuccess, hrError in
1650
+ if let hrError = hrError {
1651
+ saveError = "Failed to save heart rate samples: \(hrError.localizedDescription)"
1652
+ } else if !hrSuccess {
1653
+ saveError = "Failed to save heart rate samples"
1654
+ }
1655
+ group.leave()
1656
+ }
1657
+ }
1658
+
1659
+ group.notify(queue: .main) {
1660
+ if let saveError = saveError {
1661
+ call.reject(saveError)
1662
+ } else {
1663
+ call.resolve(["success": true, "id": workout.uuid.uuidString])
1664
+ }
1665
+ }
1666
+ }
1667
+ }
1668
+ }
1295
1669
 
1296
1670
  @objc func queryWorkouts(_ call: CAPPluginCall) {
1297
1671
  guard let startDateString = call.getString("startDate"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flomentumsolutions/capacitor-health-extended",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
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",