@craft-native/ios 0.0.138 → 0.0.139

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
@@ -371,6 +371,12 @@ await health.requestAuthorization(['workouts', 'heartRate', 'sleep'], { write: f
371
371
  const workouts = await health.getWorkouts({ startDate: Date.now() - 30 * 86400000 })
372
372
  // One value per local day: sums, averages, the latest weight, hours asleep.
373
373
  const hrv = await health.getDailyStatistics('heartRateVariability', { startDate: Date.now() - 30 * 86400000 })
374
+ // Every heart-rate reading in a range (bpm, start/end, source and device), oldest first.
375
+ const readings = await health.getHeartRateSamples({ startDate: Date.now() - 86400000 })
376
+ // The night as Apple Health recorded it: in bed, awake, and each asleep stage.
377
+ const nights = await health.getSleepIntervals({ startDate: Date.now() - 86400000 })
378
+ // An app built before these reads existed has neither; check first.
379
+ if (health.supports('getHeartRateSamples')) { /* ... */ }
374
380
 
375
381
  // Screen Capture
376
382
  const screenshotBase64 = await window.craft.takeScreenshot();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.138",
3
+ "version": "0.0.139",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2358,6 +2358,27 @@ struct CraftWebView: UIViewRepresentable {
2358
2358
  } else {
2359
2359
  rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2360
2360
  }
2361
+ case "getHealthHeartRateSamples":
2362
+ if config.enableHealthKit {
2363
+ getHealthHeartRateSamples(
2364
+ startDate: body["startDate"] as? Double,
2365
+ endDate: body["endDate"] as? Double,
2366
+ limit: body["limit"] as? Int,
2367
+ callbackId: callbackId
2368
+ )
2369
+ } else {
2370
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2371
+ }
2372
+ case "getHealthSleepIntervals":
2373
+ if config.enableHealthKit {
2374
+ getHealthSleepIntervals(
2375
+ startDate: body["startDate"] as? Double,
2376
+ endDate: body["endDate"] as? Double,
2377
+ callbackId: callbackId
2378
+ )
2379
+ } else {
2380
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2381
+ }
2361
2382
 
2362
2383
  // MARK: - Live Activities
2363
2384
  case "startLiveActivity":
@@ -4854,6 +4875,14 @@ struct CraftWebView: UIViewRepresentable {
4854
4875
  getDailyStatistics: function(type, options) {
4855
4876
  options = options || {};
4856
4877
  return craft._invoke('getHealthDailyStatistics', {type: type, startDate: options.startDate, endDate: options.endDate});
4878
+ },
4879
+ getHeartRateSamples: function(options) {
4880
+ options = options || {};
4881
+ return craft._invoke('getHealthHeartRateSamples', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
4882
+ },
4883
+ getSleepIntervals: function(options) {
4884
+ options = options || {};
4885
+ return craft._invoke('getHealthSleepIntervals', {startDate: options.startDate, endDate: options.endDate});
4857
4886
  }
4858
4887
  };
4859
4888
  craft.liveActivity = {
@@ -7260,6 +7289,117 @@ struct CraftWebView: UIViewRepresentable {
7260
7289
  healthStore.execute(query)
7261
7290
  }
7262
7291
 
7292
+ /// Every heart-rate reading between two instants, oldest first: the
7293
+ /// beats per minute, when it was taken, and the app and device that
7294
+ /// wrote it. A watch's workout readings can arrive as one series
7295
+ /// sample; those also carry their count, lowest and highest.
7296
+ private func getHealthHeartRateSamples(startDate: Double?, endDate: Double?, limit: Int?, callbackId: String?) {
7297
+ guard let healthStore = healthStore else {
7298
+ rejectCallback(callbackId, error: "HealthKit not available")
7299
+ return
7300
+ }
7301
+ guard let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) else {
7302
+ rejectCallback(callbackId, error: "Heart rate is unavailable")
7303
+ return
7304
+ }
7305
+
7306
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
7307
+ let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -1, to: end)!
7308
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
7309
+ let oldestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
7310
+ let cap = max(1, min(limit ?? 10_000, 50_000))
7311
+
7312
+ let query = HKSampleQuery(sampleType: heartType, predicate: predicate, limit: cap, sortDescriptors: [oldestFirst]) { [weak self] _, samples, error in
7313
+ if let error = error, (error as? HKError)?.code != .errorNoData {
7314
+ self?.rejectCallback(callbackId, error: error.localizedDescription)
7315
+ return
7316
+ }
7317
+
7318
+ let bpm = HKUnit.count().unitDivided(by: .minute())
7319
+ let rows: [[String: Any]] = (samples as? [HKQuantitySample] ?? []).map { sample in
7320
+ var item: [String: Any] = [
7321
+ "bpm": sample.quantity.doubleValue(for: bpm),
7322
+ "startDate": sample.startDate.timeIntervalSince1970 * 1000,
7323
+ "endDate": sample.endDate.timeIntervalSince1970 * 1000,
7324
+ "sourceName": sample.sourceRevision.source.name,
7325
+ ]
7326
+ if let device = sample.device?.name { item["deviceName"] = device }
7327
+ if let model = sample.device?.model { item["deviceModel"] = model }
7328
+ if let series = sample as? HKDiscreteQuantitySample, series.count > 1 {
7329
+ item["count"] = series.count
7330
+ item["min"] = series.minimumQuantity.doubleValue(for: bpm)
7331
+ item["max"] = series.maximumQuantity.doubleValue(for: bpm)
7332
+ }
7333
+ if let raw = sample.metadata?[HKMetadataKeyHeartRateMotionContext] as? NSNumber {
7334
+ switch HKHeartRateMotionContext(rawValue: raw.intValue) {
7335
+ case .sedentary: item["motionContext"] = "sedentary"
7336
+ case .active: item["motionContext"] = "active"
7337
+ default: break
7338
+ }
7339
+ }
7340
+ return item
7341
+ }
7342
+ self?.resolveCallback(callbackId, result: rows)
7343
+ }
7344
+ healthStore.execute(query)
7345
+ }
7346
+
7347
+ /// A stable name for each sleep analysis value: in bed, awake, or one
7348
+ /// of the asleep stages (`asleep` when the source does not stage it).
7349
+ private static func sleepStageName(_ value: Int) -> String {
7350
+ switch HKCategoryValueSleepAnalysis(rawValue: value) {
7351
+ case .inBed: return "inBed"
7352
+ case .awake: return "awake"
7353
+ case .asleepCore: return "core"
7354
+ case .asleepDeep: return "deep"
7355
+ case .asleepREM: return "rem"
7356
+ default: return "asleep"
7357
+ }
7358
+ }
7359
+
7360
+ /// Sleep between two instants, oldest first, as the sources wrote it:
7361
+ /// time in bed and every asleep or awake stretch, each with its stage
7362
+ /// and whether it counts as asleep, so a page can mark the night.
7363
+ private func getHealthSleepIntervals(startDate: Double?, endDate: Double?, callbackId: String?) {
7364
+ guard let healthStore = healthStore else {
7365
+ rejectCallback(callbackId, error: "HealthKit not available")
7366
+ return
7367
+ }
7368
+ guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
7369
+ rejectCallback(callbackId, error: "Sleep data is unavailable")
7370
+ return
7371
+ }
7372
+
7373
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
7374
+ let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -1, to: end)!
7375
+ // Any interval that overlaps the range, so a night that began
7376
+ // before it still counts.
7377
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
7378
+ let oldestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
7379
+
7380
+ let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [oldestFirst]) { [weak self] _, samples, error in
7381
+ if let error = error, (error as? HKError)?.code != .errorNoData {
7382
+ self?.rejectCallback(callbackId, error: error.localizedDescription)
7383
+ return
7384
+ }
7385
+
7386
+ let notAsleep: Set<Int> = [HKCategoryValueSleepAnalysis.inBed.rawValue, HKCategoryValueSleepAnalysis.awake.rawValue]
7387
+ let rows: [[String: Any]] = (samples as? [HKCategorySample] ?? []).map { sample in
7388
+ var item: [String: Any] = [
7389
+ "startDate": sample.startDate.timeIntervalSince1970 * 1000,
7390
+ "endDate": sample.endDate.timeIntervalSince1970 * 1000,
7391
+ "stage": Self.sleepStageName(sample.value),
7392
+ "asleep": !notAsleep.contains(sample.value),
7393
+ "sourceName": sample.sourceRevision.source.name,
7394
+ ]
7395
+ if let device = sample.device?.name { item["deviceName"] = device }
7396
+ return item
7397
+ }
7398
+ self?.resolveCallback(callbackId, result: rows)
7399
+ }
7400
+ healthStore.execute(query)
7401
+ }
7402
+
7263
7403
  // MARK: - Live Activities
7264
7404
  private func startLiveActivity(body: [String: Any], callbackId: String?) {
7265
7405
  guard #available(iOS 16.2, *), ActivityAuthorizationInfo().areActivitiesEnabled else {