@flomentumsolutions/capacitor-health-extended 0.6.4 → 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,6 +27,7 @@ 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
+ - Create workout sessions (e.g., rock climbing) with totals, optional routes, and heart-rate samples (write APIs)
30
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
 
@@ -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
 
@@ -447,6 +458,26 @@ Query latest steps sample
447
458
  --------------------
448
459
 
449
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
+
450
481
  ### Interfaces
451
482
 
452
483
 
@@ -573,6 +604,28 @@ Query latest steps sample
573
604
  | **`metadata`** | <code><a href="#record">Record</a>&lt;string, unknown&gt;</code> |
574
605
 
575
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
+
576
629
  ### Type Aliases
577
630
 
578
631
 
@@ -585,7 +638,7 @@ Construct a type with a set of properties K of type T
585
638
 
586
639
  #### HealthPermission
587
640
 
588
- <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>
589
642
 
590
643
 
591
644
  #### HealthBiologicalSex
@@ -612,4 +665,9 @@ Construct a type with a set of properties K of type T
612
665
 
613
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>
614
667
 
668
+
669
+ #### WorkoutActivityType
670
+
671
+ <code>'rock-climbing' | 'climbing' | 'hiking' | 'running' | 'walking' | 'cycling' | 'biking' | 'strength-training' | 'yoga' | 'other'</code>
672
+
615
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() {
@@ -1498,6 +1519,151 @@ class HealthPlugin : Plugin() {
1498
1519
  return granted.contains(targetPermission)
1499
1520
  }
1500
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
+
1501
1667
  @PluginMethod
1502
1668
  fun queryWorkouts(call: PluginCall) {
1503
1669
  if (!ensureClientInitialized(call)) return
@@ -1689,6 +1855,71 @@ class HealthPlugin : Plugin() {
1689
1855
  return routeArray
1690
1856
  }
1691
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
+
1692
1923
 
1693
1924
  private val exerciseTypeMapping = mapOf(
1694
1925
  0 to "OTHER",
@@ -86,8 +86,15 @@ export interface HealthPlugin {
86
86
  * Query latest steps sample
87
87
  */
88
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>;
89
96
  }
90
- 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';
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';
91
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';
92
99
  export interface PermissionsRequest {
93
100
  permissions: HealthPermission[];
@@ -115,6 +122,21 @@ export interface RouteSample {
115
122
  export interface QueryWorkoutResponse {
116
123
  workouts: Workout[];
117
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
+ }
118
140
  export interface Workout {
119
141
  startDate: string;
120
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 * - `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\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 | '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 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
@@ -634,16 +637,29 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
634
637
  HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
635
638
  HKObjectType.quantityType(forIdentifier: .basalEnergyBurned) // iOS 16+
636
639
  ].compactMap { $0 }
640
+ case "WRITE_TOTAL_CALORIES":
641
+ return [
642
+ HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
643
+ HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
644
+ ].compactMap { $0 }
637
645
  case "READ_ACTIVE_CALORIES":
638
646
  return [HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)].compactMap{$0}
647
+ case "WRITE_ACTIVE_CALORIES":
648
+ return [HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)].compactMap{$0}
639
649
  case "READ_WORKOUTS":
640
650
  return [HKObjectType.workoutType()].compactMap{$0}
651
+ case "WRITE_WORKOUTS":
652
+ return [HKObjectType.workoutType()].compactMap{$0}
641
653
  case "READ_HEART_RATE":
642
654
  return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap{$0}
655
+ case "WRITE_HEART_RATE":
656
+ return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap{$0}
643
657
  case "READ_RESTING_HEART_RATE":
644
658
  return [HKObjectType.quantityType(forIdentifier: .restingHeartRate)].compactMap { $0 }
645
659
  case "READ_ROUTE":
646
660
  return [HKSeriesType.workoutRoute()].compactMap{$0}
661
+ case "WRITE_ROUTE":
662
+ return [HKSeriesType.workoutRoute()].compactMap{$0}
647
663
  case "READ_DISTANCE":
648
664
  return [
649
665
  HKObjectType.quantityType(forIdentifier: .distanceCycling),
@@ -651,6 +667,13 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
651
667
  HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning),
652
668
  HKObjectType.quantityType(forIdentifier: .distanceDownhillSnowSports)
653
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}
654
677
  case "READ_MINDFULNESS":
655
678
  return [HKObjectType.categoryType(forIdentifier: .mindfulSession)!].compactMap{$0}
656
679
  case "READ_HRV":
@@ -754,6 +777,34 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
754
777
  }
755
778
  }
756
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
+
757
808
  private func mapBiologicalSex(_ biologicalSex: HKBiologicalSex) -> String {
758
809
  switch biologicalSex {
759
810
  case .female:
@@ -1398,8 +1449,224 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
1398
1449
  f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
1399
1450
  return f
1400
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
+ }
1401
1555
 
1402
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
+ }
1669
+
1403
1670
  @objc func queryWorkouts(_ call: CAPPluginCall) {
1404
1671
  guard let startDateString = call.getString("startDate"),
1405
1672
  let endDateString = call.getString("endDate"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flomentumsolutions/capacitor-health-extended",
3
- "version": "0.6.4",
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",