@craft-native/ios 0.0.290 → 0.0.293

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
@@ -569,6 +569,11 @@ const readings = await health.getHeartRateSamples({ startDate: Date.now() - 8640
569
569
  const nights = await health.getSleepIntervals({ startDate: Date.now() - 86400000 })
570
570
  // An app built before these reads existed has neither; check first.
571
571
  if (health.supports('getHeartRateSamples')) { /* ... */ }
572
+ // Diabetes: glucose (mg/dL), insulin (IU, each marked basal or bolus) and carbs (g),
573
+ // one sample at a time with its UUID, oldest first. Ask for them read only.
574
+ await health.requestAuthorization(['bloodGlucose', 'insulinDelivery', 'dietaryCarbohydrates'], { write: false })
575
+ const glucose = await health.getSamples('bloodGlucose', { startDate: Date.now() - 86400000 })
576
+ const doses = await health.getSamples('insulinDelivery', { startDate: Date.now() - 86400000 })
572
577
  // Mindful minutes: ask for 'mindful' (with writing allowed), then save a
573
578
  // meditation or breathing session. The id is kept as the sample's external
574
579
  // UUID, so saving it again keeps the first sample ({ existing: true }).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.290",
3
+ "version": "0.0.293",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3116,6 +3116,22 @@ struct CraftWebView: UIViewRepresentable {
3116
3116
  } else {
3117
3117
  rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
3118
3118
  }
3119
+ // Glucose, insulin and carbs, one sample at a time (CraftHealth.swift).
3120
+ case "getHealthSamples":
3121
+ if config.enableHealthKit {
3122
+ if let dataType = body["type"] as? String {
3123
+ CraftHealth.shared.getSamples(
3124
+ type: dataType,
3125
+ startDate: body["startDate"] as? Double,
3126
+ endDate: body["endDate"] as? Double,
3127
+ limit: body["limit"] as? Int
3128
+ ) { [weak self] in self?.answer(callbackId, $0) }
3129
+ } else {
3130
+ rejectCallback(callbackId, error: "getHealthSamples was called without a type", code: "INVALID_ARGUMENT")
3131
+ }
3132
+ } else {
3133
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
3134
+ }
3119
3135
  case "getHealthSleepIntervals":
3120
3136
  if config.enableHealthKit {
3121
3137
  getHealthSleepIntervals(
@@ -5749,6 +5765,10 @@ struct CraftWebView: UIViewRepresentable {
5749
5765
  options = options || {};
5750
5766
  return craft._invoke('getHealthHeartRateSamples', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
5751
5767
  },
5768
+ getSamples: function(type, options) {
5769
+ options = options || {};
5770
+ return craft._invoke('getHealthSamples', {type: type, startDate: options.startDate, endDate: options.endDate, limit: options.limit});
5771
+ },
5752
5772
  getSleepIntervals: function(options) {
5753
5773
  options = options || {};
5754
5774
  return craft._invoke('getHealthSleepIntervals', {startDate: options.startDate, endDate: options.endDate});
@@ -106,6 +106,9 @@ final class CraftHealth {
106
106
  getDailyStatistics(type: type, startDate: options(1)["startDate"] as? Double, endDate: options(1)["endDate"] as? Double, completion: completion)
107
107
  case "getHeartRateSamples":
108
108
  getHeartRateSamples(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, limit: options(0)["limit"] as? Int, completion: completion)
109
+ case "getSamples":
110
+ guard let type = args.first as? String else { return completion(.failure(CraftHealthError(message: "getSamples needs a type", code: "INVALID_ARGUMENT"))) }
111
+ getSamples(type: type, startDate: options(1)["startDate"] as? Double, endDate: options(1)["endDate"] as? Double, limit: options(1)["limit"] as? Int, completion: completion)
109
112
  case "getSleepIntervals":
110
113
  getSleepIntervals(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, completion: completion)
111
114
  case "saveMindfulSession":
@@ -196,6 +199,12 @@ final class CraftHealth {
196
199
  readTypes.insert(mindfulType)
197
200
  shareTypes.insert(mindfulType)
198
201
  }
202
+ case "bloodGlucose", "insulinDelivery":
203
+ // A CGM, meter, pump or closed loop's own writes: read
204
+ // only, never written back, so the sheet only asks to read.
205
+ if let diabetesType = Self.healthQuantity(type)?.type {
206
+ readTypes.insert(diabetesType)
207
+ }
199
208
  case "dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat":
200
209
  // What a food log (MyFitnessPal, Lose It!, Cronometer...)
201
210
  // writes to Apple Health. Request the write grant too unless
@@ -404,6 +413,8 @@ final class CraftHealth {
404
413
  case "dietaryProtein": return (HKQuantityType.quantityType(forIdentifier: .dietaryProtein), .gram(), .cumulativeSum)
405
414
  case "dietaryCarbohydrates": return (HKQuantityType.quantityType(forIdentifier: .dietaryCarbohydrates), .gram(), .cumulativeSum)
406
415
  case "dietaryFat": return (HKQuantityType.quantityType(forIdentifier: .dietaryFatTotal), .gram(), .cumulativeSum)
416
+ case "bloodGlucose": return (HKQuantityType.quantityType(forIdentifier: .bloodGlucose), HKUnit(from: "mg/dL"), .discreteAverage)
417
+ case "insulinDelivery": return (HKQuantityType.quantityType(forIdentifier: .insulinDelivery), .internationalUnit(), .cumulativeSum)
407
418
  default: return nil
408
419
  }
409
420
  }
@@ -613,6 +624,71 @@ final class CraftHealth {
613
624
  healthStore.execute(query)
614
625
  }
615
626
 
627
+ /// The types `getSamples` reads, and the unit each answers in.
628
+ private static let sampleUnits: [String: String] = ["bloodGlucose": "mg/dL", "insulinDelivery": "IU", "dietaryCarbohydrates": "g"]
629
+
630
+ /// Every glucose reading, insulin dose or carb entry between two
631
+ /// instants, oldest first, as the writing app saved it: its UUID (so a
632
+ /// sync stores it once), value, source and device, plus what a diabetes
633
+ /// app needs to tell readings and doses apart: an insulin dose's reason
634
+ /// (basal or bolus) and a glucose reading's meal time.
635
+ func getSamples(type: String, startDate: Double?, endDate: Double?, limit: Int?, completion rawCompletion: @escaping Completion) {
636
+ let completion = completeOnMain(rawCompletion)
637
+ guard let healthStore = healthStore else {
638
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
639
+ return
640
+ }
641
+ guard let unitName = Self.sampleUnits[type], let spec = Self.healthQuantity(type), let sampleType = spec.type else {
642
+ completion(.failure(CraftHealthError(message: "getSamples reads bloodGlucose, insulinDelivery or dietaryCarbohydrates", code: "INVALID_ARGUMENT")))
643
+ return
644
+ }
645
+
646
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
647
+ let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -7, to: end)!
648
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
649
+ let oldestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
650
+ let cap = max(1, min(limit ?? 10_000, 50_000))
651
+ let ownBundle = Bundle.main.bundleIdentifier
652
+
653
+ let query = HKSampleQuery(sampleType: sampleType, predicate: predicate, limit: cap, sortDescriptors: [oldestFirst]) { _, samples, error in
654
+ if let error = error, (error as? HKError)?.code != .errorNoData {
655
+ completion(.failure(CraftHealthError(message: error.localizedDescription)))
656
+ return
657
+ }
658
+ let rows: [[String: Any]] = (samples as? [HKQuantitySample] ?? []).map { sample in
659
+ let source = sample.sourceRevision.source
660
+ var item: [String: Any] = [
661
+ "id": sample.uuid.uuidString,
662
+ "value": sample.quantity.doubleValue(for: spec.unit),
663
+ "unit": unitName,
664
+ "startDate": sample.startDate.timeIntervalSince1970 * 1000,
665
+ "endDate": sample.endDate.timeIntervalSince1970 * 1000,
666
+ "sourceName": source.name,
667
+ "sourceBundleId": source.bundleIdentifier,
668
+ "ownApp": ownBundle != nil && source.bundleIdentifier == ownBundle,
669
+ ]
670
+ if let device = sample.device?.name { item["deviceName"] = device }
671
+ if let reason = sample.metadata?[HKMetadataKeyInsulinDeliveryReason] as? NSNumber {
672
+ switch HKInsulinDeliveryReason(rawValue: reason.intValue) {
673
+ case .basal: item["insulinReason"] = "basal"
674
+ case .bolus: item["insulinReason"] = "bolus"
675
+ default: break
676
+ }
677
+ }
678
+ if let meal = sample.metadata?[HKMetadataKeyBloodGlucoseMealTime] as? NSNumber {
679
+ switch HKBloodGlucoseMealTime(rawValue: meal.intValue) {
680
+ case .preprandial: item["mealTime"] = "beforeMeal"
681
+ case .postprandial: item["mealTime"] = "afterMeal"
682
+ default: break
683
+ }
684
+ }
685
+ return item
686
+ }
687
+ completion(.success(rows))
688
+ }
689
+ healthStore.execute(query)
690
+ }
691
+
616
692
  /// One heart-rate sample as a page sees it, from a read or the stream
617
693
  /// alike: bpm, when, the app and device that wrote it, a series' count,
618
694
  /// lowest and highest, and what the wearer was doing when the watch says.