@capgo/capacitor-health 8.2.6 → 8.2.7

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
@@ -1,9 +1,19 @@
1
1
  # @capgo/capacitor-health
2
- <a href="https://capgo.app/"><img src='https://raw.githubusercontent.com/Cap-go/capgo/main/assets/capgo_banner.png' alt='Capgo - Instant updates for capacitor'/></a>
2
+
3
+ <a href="https://capgo.app/">
4
+ <img
5
+ src="https://raw.githubusercontent.com/Cap-go/capgo/main/assets/capgo_banner.png"
6
+ alt="Capgo - Instant updates for capacitor"
7
+ />
8
+ </a>
3
9
 
4
10
  <div align="center">
5
- <h2><a href="https://capgo.app/?ref=plugin_health"> ➡️ Get Instant updates for your App with Capgo</a></h2>
6
- <h2><a href="https://capgo.app/consulting/?ref=plugin_health"> Missing a feature? We’ll build the plugin for you 💪</a></h2>
11
+ <h2>
12
+ <a href="https://capgo.app/?ref=plugin_health"> ➡️ Get Instant updates for your App with Capgo</a>
13
+ </h2>
14
+ <h2>
15
+ <a href="https://capgo.app/consulting/?ref=plugin_health"> Missing a feature? We’ll build the plugin for you 💪</a>
16
+ </h2>
7
17
  </div>
8
18
 
9
19
  Capacitor plugin to read and write health metrics via Apple HealthKit (iOS) and Health Connect (Android). The TypeScript API keeps the same data types and units across platforms so you can build once and deploy everywhere.
@@ -51,7 +61,7 @@ This plugin now uses [Health Connect](https://developer.android.com/health-and-f
51
61
 
52
62
  1. **Min SDK 26+.** Health Connect is only available on Android 8.0 (API 26) and above. The plugin's Gradle setup already targets this level.
53
63
  2. **Declare Health permissions.** The plugin manifest ships with the required `<uses-permission>` declarations (`READ_/WRITE_STEPS`, `READ_/WRITE_DISTANCE`, `READ_/WRITE_ACTIVE_CALORIES_BURNED`, `READ_/WRITE_HEART_RATE`, `READ_/WRITE_WEIGHT`). Your app does not need to duplicate them, but you must surface a user-facing rationale because the permissions are considered health sensitive.
54
- 3. **Ensure Health Connect is installed.** Devices on Android 14+ include it by default. For earlier versions the user must install *Health Connect by Android* from the Play Store. The `Health.isAvailable()` helper exposes the current status so you can prompt accordingly.
64
+ 3. **Ensure Health Connect is installed.** Devices on Android 14+ include it by default. For earlier versions the user must install _Health Connect by Android_ from the Play Store. The `Health.isAvailable()` helper exposes the current status so you can prompt accordingly.
55
65
  4. **Request runtime access.** The plugin opens the Health Connect permission UI when you call `requestAuthorization`. You should still handle denial flows (e.g., show a message if `checkAuthorization` reports missing scopes).
56
66
  5. **Provide a Privacy Policy.** Health Connect requires apps to display a privacy policy explaining how health data is used. See the [Privacy Policy Setup](#privacy-policy-setup) section below.
57
67
 
@@ -68,17 +78,17 @@ Place an HTML file at `android/app/src/main/assets/public/privacypolicy.html`:
68
78
  ```html
69
79
  <!DOCTYPE html>
70
80
  <html>
71
- <head>
72
- <meta charset="utf-8">
73
- <meta name="viewport" content="width=device-width, initial-scale=1">
81
+ <head>
82
+ <meta charset="utf-8" />
83
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
74
84
  <title>Privacy Policy</title>
75
- </head>
76
- <body>
85
+ </head>
86
+ <body>
77
87
  <h1>Privacy Policy</h1>
78
88
  <p>Your privacy policy content here...</p>
79
89
  <h2>Health Data</h2>
80
90
  <p>Explain how you collect, use, and protect health data...</p>
81
- </body>
91
+ </body>
82
92
  </html>
83
93
  ```
84
94
 
@@ -141,22 +151,49 @@ await Health.saveSample({
141
151
 
142
152
  ### Supported data types
143
153
 
144
- | Identifier | Default unit | Notes |
145
- | ---------- | ------------- | ----- |
146
- | `steps` | `count` | Step count deltas |
147
- | `distance` | `meter` | Walking / running distance |
148
- | `calories` | `kilocalorie` | Active energy burned |
149
- | `heartRate`| `bpm` | Beats per minute |
150
- | `weight` | `kilogram` | Body mass |
151
- | `workouts` | N/A | Workout sessions (read-only, use with `queryWorkouts()`) |
154
+ | Identifier | Default unit | Notes |
155
+ | ----------- | ------------- | -------------------------------------------------------- |
156
+ | `steps` | `count` | Step count deltas |
157
+ | `distance` | `meter` | Walking / running distance |
158
+ | `calories` | `kilocalorie` | Active energy burned |
159
+ | `heartRate` | `bpm` | Beats per minute |
160
+ | `weight` | `kilogram` | Body mass |
161
+ | `workouts` | N/A | Workout sessions (read-only, use with `queryWorkouts()`) |
152
162
 
153
163
  All write operations expect the default unit shown above. On Android the `metadata` option is currently ignored by Health Connect.
154
164
 
155
165
  **Note about workouts:** To query workout data using `queryWorkouts()`, you need to explicitly request `workouts` permission:
166
+
156
167
  ```ts
157
168
  await Health.requestAuthorization({
158
- read: ['steps', 'workouts'], // Include 'workouts' to access workout sessions
169
+ read: ['steps', 'workouts'], // Include 'workouts' to access workout sessions
170
+ });
171
+ ```
172
+
173
+ **Pagination example:** Use the `anchor` parameter to paginate through workout results:
174
+
175
+ ```ts
176
+ // First page: get the first 10 workouts
177
+ let result = await Health.queryWorkouts({
178
+ startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), // Last 30 days
179
+ endDate: new Date().toISOString(),
180
+ limit: 10,
159
181
  });
182
+
183
+ console.log(`Found ${result.workouts.length} workouts`);
184
+
185
+ // If there are more results, the anchor will be set
186
+ while (result.anchor) {
187
+ // Next page: use the anchor to continue from where we left off
188
+ result = await Health.queryWorkouts({
189
+ startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
190
+ endDate: new Date().toISOString(),
191
+ limit: 10,
192
+ anchor: result.anchor, // Continue from the last result
193
+ });
194
+
195
+ console.log(`Found ${result.workouts.length} more workouts`);
196
+ }
160
197
  ```
161
198
 
162
199
  ## API
@@ -399,9 +436,10 @@ Supported on iOS (HealthKit) and Android (Health Connect).
399
436
 
400
437
  #### QueryWorkoutsResult
401
438
 
402
- | Prop | Type |
403
- | -------------- | ---------------------- |
404
- | **`workouts`** | <code>Workout[]</code> |
439
+ | Prop | Type | Description |
440
+ | -------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
441
+ | **`workouts`** | <code>Workout[]</code> | |
442
+ | **`anchor`** | <code>string</code> | Anchor for the next page of results. Pass this value as the anchor parameter in the next query to continue pagination. If undefined or null, there are no more results. |
405
443
 
406
444
 
407
445
  #### Workout
@@ -421,13 +459,14 @@ Supported on iOS (HealthKit) and Android (Health Connect).
421
459
 
422
460
  #### QueryWorkoutsOptions
423
461
 
424
- | Prop | Type | Description |
425
- | ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------- |
426
- | **`workoutType`** | <code><a href="#workouttype">WorkoutType</a></code> | Optional workout type filter. If omitted, all workout types are returned. |
427
- | **`startDate`** | <code>string</code> | Inclusive ISO 8601 start date (defaults to now - 1 day). |
428
- | **`endDate`** | <code>string</code> | Exclusive ISO 8601 end date (defaults to now). |
429
- | **`limit`** | <code>number</code> | Maximum number of workouts to return (defaults to 100). |
430
- | **`ascending`** | <code>boolean</code> | Return results sorted ascending by start date (defaults to false). |
462
+ | Prop | Type | Description |
463
+ | ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
464
+ | **`workoutType`** | <code><a href="#workouttype">WorkoutType</a></code> | Optional workout type filter. If omitted, all workout types are returned. |
465
+ | **`startDate`** | <code>string</code> | Inclusive ISO 8601 start date (defaults to now - 1 day). |
466
+ | **`endDate`** | <code>string</code> | Exclusive ISO 8601 end date (defaults to now). |
467
+ | **`limit`** | <code>number</code> | Maximum number of workouts to return (defaults to 100). |
468
+ | **`ascending`** | <code>boolean</code> | Return results sorted ascending by start date (defaults to false). |
469
+ | **`anchor`** | <code>string</code> | Anchor for pagination. Use the anchor returned from a previous query to continue from that point. On iOS, this uses HKQueryAnchor. On Android, this uses Health Connect's pageToken. Omit this parameter to start from the beginning. |
431
470
 
432
471
 
433
472
  ### Type Aliases
@@ -25,6 +25,7 @@ import java.time.ZoneOffset
25
25
  import java.time.format.DateTimeFormatter
26
26
  import kotlin.math.min
27
27
  import kotlin.collections.buildSet
28
+ import kotlinx.coroutines.CancellationException
28
29
 
29
30
  class HealthManager {
30
31
 
@@ -298,11 +299,12 @@ class HealthManager {
298
299
  startTime: Instant,
299
300
  endTime: Instant,
300
301
  limit: Int,
301
- ascending: Boolean
302
- ): JSArray {
302
+ ascending: Boolean,
303
+ anchor: String?
304
+ ): JSObject {
303
305
  val workouts = mutableListOf<Pair<Instant, JSObject>>()
304
306
 
305
- var pageToken: String? = null
307
+ var pageToken: String? = anchor // Use anchor as initial pageToken (leverages Health Connect's native pagination)
306
308
  val pageSize = if (limit > 0) min(limit, MAX_PAGE_SIZE) else DEFAULT_PAGE_SIZE
307
309
  var fetched = 0
308
310
 
@@ -341,7 +343,15 @@ class HealthManager {
341
343
 
342
344
  val array = JSArray()
343
345
  limited.forEach { array.put(it.second) }
344
- return array
346
+
347
+ // Return result with workouts and next anchor (pageToken)
348
+ val result = JSObject()
349
+ result.put("workouts", array)
350
+ // Only include anchor if there might be more results
351
+ if (pageToken != null) {
352
+ result.put("anchor", pageToken)
353
+ }
354
+ return result
345
355
  }
346
356
 
347
357
  private suspend fun aggregateWorkoutData(
@@ -352,27 +362,42 @@ class HealthManager {
352
362
  // Don't filter by dataOrigin - distance might come from different sources
353
363
  // than the workout session itself (e.g., fitness tracker vs workout app)
354
364
 
355
- // Aggregate distance
356
- val distanceAggregate = try {
365
+ // Aggregate distance and calories in a single request for efficiency
366
+ var distanceAggregate: Double? = null
367
+ var caloriesAggregate: Double? = null
368
+
369
+ try {
357
370
  val aggregateRequest = AggregateRequest(
358
- metrics = setOf(DistanceRecord.DISTANCE_TOTAL),
371
+ metrics = setOf(
372
+ DistanceRecord.DISTANCE_TOTAL,
373
+ ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL
374
+ ),
359
375
  timeRangeFilter = timeRange
360
- // Removed dataOriginFilter to get distance from all sources during workout time
376
+ // Removed dataOriginFilter to get data from all sources during workout time
361
377
  )
362
378
  val result = client.aggregate(aggregateRequest)
363
- result[DistanceRecord.DISTANCE_TOTAL]?.inMeters
379
+ distanceAggregate = result[DistanceRecord.DISTANCE_TOTAL]?.inMeters
380
+ caloriesAggregate = result[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
381
+ } catch (e: CancellationException) {
382
+ // Rethrow cancellation to allow coroutine cancellation to propagate
383
+ throw e
384
+ } catch (e: SecurityException) {
385
+ // Permission not granted for one or both metrics
386
+ android.util.Log.d("HealthManager", "Permission denied for workout data aggregation: ${e.message}", e)
364
387
  } catch (e: Exception) {
365
- android.util.Log.d("HealthManager", "Distance aggregation failed for workout: ${e.message}", e)
366
- null // Permission might not be granted or no data available
388
+ // Other errors (e.g., no data available)
389
+ android.util.Log.d("HealthManager", "Workout data aggregation failed: ${e.message}", e)
367
390
  }
368
391
 
369
392
  return WorkoutAggregatedData(
370
- totalDistance = distanceAggregate
393
+ totalDistance = distanceAggregate,
394
+ totalEnergyBurned = caloriesAggregate
371
395
  )
372
396
  }
373
397
 
374
398
  private data class WorkoutAggregatedData(
375
- val totalDistance: Double?
399
+ val totalDistance: Double?,
400
+ val totalEnergyBurned: Double?
376
401
  )
377
402
 
378
403
  private fun createWorkoutPayload(session: ExerciseSessionRecord, aggregatedData: WorkoutAggregatedData): JSObject {
@@ -394,6 +419,11 @@ class HealthManager {
394
419
  payload.put("totalDistance", distance)
395
420
  }
396
421
 
422
+ // Total energy burned (aggregated from ActiveCaloriesBurnedRecord)
423
+ aggregatedData.totalEnergyBurned?.let { energy ->
424
+ payload.put("totalEnergyBurned", energy)
425
+ }
426
+
397
427
  // Source information
398
428
  val dataOrigin = session.metadata.dataOrigin
399
429
  payload.put("sourceId", dataOrigin.packageName)
@@ -353,6 +353,7 @@ class HealthPlugin : Plugin() {
353
353
  val workoutType = call.getString("workoutType")
354
354
  val limit = (call.getInt("limit") ?: DEFAULT_LIMIT).coerceAtLeast(0)
355
355
  val ascending = call.getBoolean("ascending") ?: false
356
+ val anchor = call.getString("anchor")
356
357
 
357
358
  val startInstant = try {
358
359
  manager.parseInstant(call.getString("startDate"), Instant.now().minus(DEFAULT_PAST_DURATION))
@@ -376,8 +377,7 @@ class HealthPlugin : Plugin() {
376
377
  pluginScope.launch {
377
378
  val client = getClientOrReject(call) ?: return@launch
378
379
  try {
379
- val workouts = manager.queryWorkouts(client, workoutType, startInstant, endInstant, limit, ascending)
380
- val result = JSObject().apply { put("workouts", workouts) }
380
+ val result = manager.queryWorkouts(client, workoutType, startInstant, endInstant, limit, ascending, anchor)
381
381
  call.resolve(result)
382
382
  } catch (e: Exception) {
383
383
  call.reject(e.message ?: "Failed to query workouts.", null, e)
package/dist/docs.json CHANGED
@@ -477,6 +477,13 @@
477
477
  "Workout"
478
478
  ],
479
479
  "type": "Workout[]"
480
+ },
481
+ {
482
+ "name": "anchor",
483
+ "tags": [],
484
+ "docs": "Anchor for the next page of results. Pass this value as the anchor parameter in the next query\nto continue pagination. If undefined or null, there are no more results.",
485
+ "complexTypes": [],
486
+ "type": "string | undefined"
480
487
  }
481
488
  ]
482
489
  },
@@ -599,6 +606,13 @@
599
606
  "docs": "Return results sorted ascending by start date (defaults to false).",
600
607
  "complexTypes": [],
601
608
  "type": "boolean | undefined"
609
+ },
610
+ {
611
+ "name": "anchor",
612
+ "tags": [],
613
+ "docs": "Anchor for pagination. Use the anchor returned from a previous query to continue from that point.\nOn iOS, this uses HKQueryAnchor. On Android, this uses Health Connect's pageToken.\nOmit this parameter to start from the beginning.",
614
+ "complexTypes": [],
615
+ "type": "string | undefined"
602
616
  }
603
617
  ]
604
618
  }
@@ -54,6 +54,12 @@ export interface QueryWorkoutsOptions {
54
54
  limit?: number;
55
55
  /** Return results sorted ascending by start date (defaults to false). */
56
56
  ascending?: boolean;
57
+ /**
58
+ * Anchor for pagination. Use the anchor returned from a previous query to continue from that point.
59
+ * On iOS, this uses HKQueryAnchor. On Android, this uses Health Connect's pageToken.
60
+ * Omit this parameter to start from the beginning.
61
+ */
62
+ anchor?: string;
57
63
  }
58
64
  export interface Workout {
59
65
  /** The type of workout. */
@@ -77,6 +83,11 @@ export interface Workout {
77
83
  }
78
84
  export interface QueryWorkoutsResult {
79
85
  workouts: Workout[];
86
+ /**
87
+ * Anchor for the next page of results. Pass this value as the anchor parameter in the next query
88
+ * to continue pagination. If undefined or null, there are no more results.
89
+ */
90
+ anchor?: string;
80
91
  }
81
92
  export interface WriteSampleOptions {
82
93
  dataType: HealthDataType;
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export type HealthDataType = 'steps' | 'distance' | 'calories' | 'heartRate' | 'weight';\n\nexport type HealthUnit = 'count' | 'meter' | 'kilocalorie' | 'bpm' | 'kilogram';\n\nexport interface AuthorizationOptions {\n /** Data types that should be readable after authorization. */\n read?: HealthDataType[];\n /** Data types that should be writable after authorization. */\n write?: HealthDataType[];\n}\n\nexport interface AuthorizationStatus {\n readAuthorized: HealthDataType[];\n readDenied: HealthDataType[];\n writeAuthorized: HealthDataType[];\n writeDenied: HealthDataType[];\n}\n\nexport interface AvailabilityResult {\n available: boolean;\n /** Platform specific details (for debugging/diagnostics). */\n platform?: 'ios' | 'android' | 'web';\n reason?: string;\n}\n\nexport interface QueryOptions {\n /** The type of data to retrieve from the health store. */\n dataType: HealthDataType;\n /** Inclusive ISO 8601 start date (defaults to now - 1 day). */\n startDate?: string;\n /** Exclusive ISO 8601 end date (defaults to now). */\n endDate?: string;\n /** Maximum number of samples to return (defaults to 100). */\n limit?: number;\n /** Return results sorted ascending by start date (defaults to false). */\n ascending?: boolean;\n}\n\nexport interface HealthSample {\n dataType: HealthDataType;\n value: number;\n unit: HealthUnit;\n startDate: string;\n endDate: string;\n sourceName?: string;\n sourceId?: string;\n}\n\nexport interface ReadSamplesResult {\n samples: HealthSample[];\n}\n\nexport type WorkoutType =\n | 'running'\n | 'cycling'\n | 'walking'\n | 'swimming'\n | 'yoga'\n | 'strengthTraining'\n | 'hiking'\n | 'tennis'\n | 'basketball'\n | 'soccer'\n | 'americanFootball'\n | 'baseball'\n | 'crossTraining'\n | 'elliptical'\n | 'rowing'\n | 'stairClimbing'\n | 'traditionalStrengthTraining'\n | 'waterFitness'\n | 'waterPolo'\n | 'waterSports'\n | 'wrestling'\n | 'other';\n\nexport interface QueryWorkoutsOptions {\n /** Optional workout type filter. If omitted, all workout types are returned. */\n workoutType?: WorkoutType;\n /** Inclusive ISO 8601 start date (defaults to now - 1 day). */\n startDate?: string;\n /** Exclusive ISO 8601 end date (defaults to now). */\n endDate?: string;\n /** Maximum number of workouts to return (defaults to 100). */\n limit?: number;\n /** Return results sorted ascending by start date (defaults to false). */\n ascending?: boolean;\n}\n\nexport interface Workout {\n /** The type of workout. */\n workoutType: WorkoutType;\n /** Duration of the workout in seconds. */\n duration: number;\n /** Total energy burned in kilocalories (if available). */\n totalEnergyBurned?: number;\n /** Total distance in meters (if available). */\n totalDistance?: number;\n /** ISO 8601 start date of the workout. */\n startDate: string;\n /** ISO 8601 end date of the workout. */\n endDate: string;\n /** Source name that recorded the workout. */\n sourceName?: string;\n /** Source bundle identifier. */\n sourceId?: string;\n /** Additional metadata (if available). */\n metadata?: Record<string, string>;\n}\n\nexport interface QueryWorkoutsResult {\n workouts: Workout[];\n}\n\nexport interface WriteSampleOptions {\n dataType: HealthDataType;\n value: number;\n /**\n * Optional unit override. If omitted, the default unit for the data type is used\n * (count for `steps`, meter for `distance`, kilocalorie for `calories`, bpm for `heartRate`, kilogram for `weight`).\n */\n unit?: HealthUnit;\n /** ISO 8601 start date for the sample. Defaults to now. */\n startDate?: string;\n /** ISO 8601 end date for the sample. Defaults to startDate. */\n endDate?: string;\n /** Metadata key-value pairs forwarded to the native APIs where supported. */\n metadata?: Record<string, string>;\n}\n\nexport interface HealthPlugin {\n /** Returns whether the current platform supports the native health SDK. */\n isAvailable(): Promise<AvailabilityResult>;\n /** Requests read/write access to the provided data types. */\n requestAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Checks authorization status for the provided data types without prompting the user. */\n checkAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Reads samples for the given data type within the specified time frame. */\n readSamples(options: QueryOptions): Promise<ReadSamplesResult>;\n /** Writes a single sample to the native health store. */\n saveSample(options: WriteSampleOptions): Promise<void>;\n\n /**\n * Get the native Capacitor plugin version\n *\n * @returns {Promise<{ version: string }>} a Promise with version for this device\n * @throws An error if something went wrong\n */\n getPluginVersion(): Promise<{ version: string }>;\n\n /**\n * Opens the Health Connect settings screen (Android only).\n * On iOS, this method does nothing.\n *\n * Use this to direct users to manage their Health Connect permissions\n * or to install Health Connect if not available.\n *\n * @throws An error if Health Connect settings cannot be opened\n */\n openHealthConnectSettings(): Promise<void>;\n\n /**\n * Shows the app's privacy policy for Health Connect (Android only).\n * On iOS, this method does nothing.\n *\n * This displays the same privacy policy screen that Health Connect shows\n * when the user taps \"Privacy policy\" in the permissions dialog.\n *\n * The privacy policy URL can be configured by adding a string resource\n * named \"health_connect_privacy_policy_url\" in your app's strings.xml,\n * or by placing an HTML file at www/privacypolicy.html in your assets.\n *\n * @throws An error if the privacy policy cannot be displayed\n */\n showPrivacyPolicy(): Promise<void>;\n\n /**\n * Queries workout sessions from the native health store.\n * Supported on iOS (HealthKit) and Android (Health Connect).\n *\n * @param options Query options including optional workout type filter, date range, limit, and sort order\n * @returns A promise that resolves with the workout sessions\n * @throws An error if something went wrong\n */\n queryWorkouts(options: QueryWorkoutsOptions): Promise<QueryWorkoutsResult>;\n}\n"]}
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export type HealthDataType = 'steps' | 'distance' | 'calories' | 'heartRate' | 'weight';\n\nexport type HealthUnit = 'count' | 'meter' | 'kilocalorie' | 'bpm' | 'kilogram';\n\nexport interface AuthorizationOptions {\n /** Data types that should be readable after authorization. */\n read?: HealthDataType[];\n /** Data types that should be writable after authorization. */\n write?: HealthDataType[];\n}\n\nexport interface AuthorizationStatus {\n readAuthorized: HealthDataType[];\n readDenied: HealthDataType[];\n writeAuthorized: HealthDataType[];\n writeDenied: HealthDataType[];\n}\n\nexport interface AvailabilityResult {\n available: boolean;\n /** Platform specific details (for debugging/diagnostics). */\n platform?: 'ios' | 'android' | 'web';\n reason?: string;\n}\n\nexport interface QueryOptions {\n /** The type of data to retrieve from the health store. */\n dataType: HealthDataType;\n /** Inclusive ISO 8601 start date (defaults to now - 1 day). */\n startDate?: string;\n /** Exclusive ISO 8601 end date (defaults to now). */\n endDate?: string;\n /** Maximum number of samples to return (defaults to 100). */\n limit?: number;\n /** Return results sorted ascending by start date (defaults to false). */\n ascending?: boolean;\n}\n\nexport interface HealthSample {\n dataType: HealthDataType;\n value: number;\n unit: HealthUnit;\n startDate: string;\n endDate: string;\n sourceName?: string;\n sourceId?: string;\n}\n\nexport interface ReadSamplesResult {\n samples: HealthSample[];\n}\n\nexport type WorkoutType =\n | 'running'\n | 'cycling'\n | 'walking'\n | 'swimming'\n | 'yoga'\n | 'strengthTraining'\n | 'hiking'\n | 'tennis'\n | 'basketball'\n | 'soccer'\n | 'americanFootball'\n | 'baseball'\n | 'crossTraining'\n | 'elliptical'\n | 'rowing'\n | 'stairClimbing'\n | 'traditionalStrengthTraining'\n | 'waterFitness'\n | 'waterPolo'\n | 'waterSports'\n | 'wrestling'\n | 'other';\n\nexport interface QueryWorkoutsOptions {\n /** Optional workout type filter. If omitted, all workout types are returned. */\n workoutType?: WorkoutType;\n /** Inclusive ISO 8601 start date (defaults to now - 1 day). */\n startDate?: string;\n /** Exclusive ISO 8601 end date (defaults to now). */\n endDate?: string;\n /** Maximum number of workouts to return (defaults to 100). */\n limit?: number;\n /** Return results sorted ascending by start date (defaults to false). */\n ascending?: boolean;\n /**\n * Anchor for pagination. Use the anchor returned from a previous query to continue from that point.\n * On iOS, this uses HKQueryAnchor. On Android, this uses Health Connect's pageToken.\n * Omit this parameter to start from the beginning.\n */\n anchor?: string;\n}\n\nexport interface Workout {\n /** The type of workout. */\n workoutType: WorkoutType;\n /** Duration of the workout in seconds. */\n duration: number;\n /** Total energy burned in kilocalories (if available). */\n totalEnergyBurned?: number;\n /** Total distance in meters (if available). */\n totalDistance?: number;\n /** ISO 8601 start date of the workout. */\n startDate: string;\n /** ISO 8601 end date of the workout. */\n endDate: string;\n /** Source name that recorded the workout. */\n sourceName?: string;\n /** Source bundle identifier. */\n sourceId?: string;\n /** Additional metadata (if available). */\n metadata?: Record<string, string>;\n}\n\nexport interface QueryWorkoutsResult {\n workouts: Workout[];\n /**\n * Anchor for the next page of results. Pass this value as the anchor parameter in the next query\n * to continue pagination. If undefined or null, there are no more results.\n */\n anchor?: string;\n}\n\nexport interface WriteSampleOptions {\n dataType: HealthDataType;\n value: number;\n /**\n * Optional unit override. If omitted, the default unit for the data type is used\n * (count for `steps`, meter for `distance`, kilocalorie for `calories`, bpm for `heartRate`, kilogram for `weight`).\n */\n unit?: HealthUnit;\n /** ISO 8601 start date for the sample. Defaults to now. */\n startDate?: string;\n /** ISO 8601 end date for the sample. Defaults to startDate. */\n endDate?: string;\n /** Metadata key-value pairs forwarded to the native APIs where supported. */\n metadata?: Record<string, string>;\n}\n\nexport interface HealthPlugin {\n /** Returns whether the current platform supports the native health SDK. */\n isAvailable(): Promise<AvailabilityResult>;\n /** Requests read/write access to the provided data types. */\n requestAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Checks authorization status for the provided data types without prompting the user. */\n checkAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Reads samples for the given data type within the specified time frame. */\n readSamples(options: QueryOptions): Promise<ReadSamplesResult>;\n /** Writes a single sample to the native health store. */\n saveSample(options: WriteSampleOptions): Promise<void>;\n\n /**\n * Get the native Capacitor plugin version\n *\n * @returns {Promise<{ version: string }>} a Promise with version for this device\n * @throws An error if something went wrong\n */\n getPluginVersion(): Promise<{ version: string }>;\n\n /**\n * Opens the Health Connect settings screen (Android only).\n * On iOS, this method does nothing.\n *\n * Use this to direct users to manage their Health Connect permissions\n * or to install Health Connect if not available.\n *\n * @throws An error if Health Connect settings cannot be opened\n */\n openHealthConnectSettings(): Promise<void>;\n\n /**\n * Shows the app's privacy policy for Health Connect (Android only).\n * On iOS, this method does nothing.\n *\n * This displays the same privacy policy screen that Health Connect shows\n * when the user taps \"Privacy policy\" in the permissions dialog.\n *\n * The privacy policy URL can be configured by adding a string resource\n * named \"health_connect_privacy_policy_url\" in your app's strings.xml,\n * or by placing an HTML file at www/privacypolicy.html in your assets.\n *\n * @throws An error if the privacy policy cannot be displayed\n */\n showPrivacyPolicy(): Promise<void>;\n\n /**\n * Queries workout sessions from the native health store.\n * Supported on iOS (HealthKit) and Android (Health Connect).\n *\n * @param options Query options including optional workout type filter, date range, limit, and sort order\n * @returns A promise that resolves with the workout sessions\n * @throws An error if something went wrong\n */\n queryWorkouts(options: QueryWorkoutsOptions): Promise<QueryWorkoutsResult>;\n}\n"]}
@@ -236,6 +236,9 @@ struct AuthorizationStatusPayload {
236
236
  final class Health {
237
237
  private let healthStore = HKHealthStore()
238
238
  private let isoFormatter: ISO8601DateFormatter
239
+
240
+ /// Small time offset (in seconds) added to the last workout's end date to avoid duplicate results in pagination
241
+ private let paginationOffsetSeconds: TimeInterval = 0.001
239
242
 
240
243
  init() {
241
244
  let formatter = ISO8601DateFormatter()
@@ -559,7 +562,7 @@ final class Health {
559
562
  return set
560
563
  }
561
564
 
562
- func queryWorkouts(workoutTypeString: String?, startDateString: String?, endDateString: String?, limit: Int?, ascending: Bool, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
565
+ func queryWorkouts(workoutTypeString: String?, startDateString: String?, endDateString: String?, limit: Int?, ascending: Bool, anchorString: String?, completion: @escaping (Result<[String: Any], Error>) -> Void) {
563
566
  let startDate = (try? parseDate(startDateString, defaultValue: Date().addingTimeInterval(-86400))) ?? Date().addingTimeInterval(-86400)
564
567
  let endDate = (try? parseDate(endDateString, defaultValue: Date())) ?? Date()
565
568
 
@@ -568,7 +571,16 @@ final class Health {
568
571
  return
569
572
  }
570
573
 
571
- var predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
574
+ // If anchor is provided, use it as the continuation point for pagination.
575
+ // The anchor is the ISO 8601 date string of the last workout's end date from the previous query.
576
+ let effectiveStartDate: Date
577
+ if let anchorString = anchorString, let anchorDate = try? parseDate(anchorString, defaultValue: startDate) {
578
+ effectiveStartDate = anchorDate
579
+ } else {
580
+ effectiveStartDate = startDate
581
+ }
582
+
583
+ var predicate = HKQuery.predicateForSamples(withStart: effectiveStartDate, end: endDate, options: [])
572
584
 
573
585
  // Filter by workout type if specified
574
586
  if let workoutTypeString = workoutTypeString, let workoutType = WorkoutType(rawValue: workoutTypeString) {
@@ -594,7 +606,7 @@ final class Health {
594
606
  }
595
607
 
596
608
  guard let workouts = samples as? [HKWorkout] else {
597
- completion(.success([]))
609
+ completion(.success(["workouts": []]))
598
610
  return
599
611
  }
600
612
 
@@ -641,7 +653,17 @@ final class Health {
641
653
  return payload
642
654
  }
643
655
 
644
- completion(.success(results))
656
+ // Generate next anchor if we have results and reached the limit
657
+ var response: [String: Any] = ["workouts": results]
658
+ if !workouts.isEmpty && workouts.count >= queryLimit {
659
+ // Use the last workout's end date as the anchor for the next page
660
+ let lastWorkout = workouts.last!
661
+ // Add a small offset to avoid getting the same workout again
662
+ let nextAnchorDate = lastWorkout.endDate.addingTimeInterval(self.paginationOffsetSeconds)
663
+ response["anchor"] = self.isoFormatter.string(from: nextAnchorDate)
664
+ }
665
+
666
+ completion(.success(response))
645
667
  }
646
668
 
647
669
  healthStore.execute(query)
@@ -3,7 +3,7 @@ import Capacitor
3
3
 
4
4
  @objc(HealthPlugin)
5
5
  public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
6
- private let pluginVersion: String = "8.2.6"
6
+ private let pluginVersion: String = "8.2.7"
7
7
  public let identifier = "HealthPlugin"
8
8
  public let jsName = "Health"
9
9
  public let pluginMethods: [CAPPluginMethod] = [
@@ -153,18 +153,20 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
153
153
  let endDate = call.getString("endDate")
154
154
  let limit = call.getInt("limit")
155
155
  let ascending = call.getBool("ascending") ?? false
156
+ let anchor = call.getString("anchor")
156
157
 
157
158
  implementation.queryWorkouts(
158
159
  workoutTypeString: workoutType,
159
160
  startDateString: startDate,
160
161
  endDateString: endDate,
161
162
  limit: limit,
162
- ascending: ascending
163
+ ascending: ascending,
164
+ anchorString: anchor
163
165
  ) { result in
164
166
  DispatchQueue.main.async {
165
167
  switch result {
166
- case let .success(workouts):
167
- call.resolve(["workouts": workouts])
168
+ case let .success(response):
169
+ call.resolve(response)
168
170
  case let .failure(error):
169
171
  call.reject(error.localizedDescription, nil, error)
170
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-health",
3
- "version": "8.2.6",
3
+ "version": "8.2.7",
4
4
  "description": "Capacitor plugin to interact with data from Apple HealthKit and Health Connect",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",