craft-native 0.0.139 → 0.0.141

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/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.139";
3306
+ var version = "0.0.141";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.139";
313
+ var version = "0.0.141";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.139";
61
+ var version = "0.0.141";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -778,6 +778,7 @@ async function init(options) {
778
778
  cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
779
779
  cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
780
780
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
781
+ cpSync(join(TEMPLATES_DIR, "CraftHealth.swift"), join(output, "Sources", "CraftHealth.swift"));
781
782
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
782
783
  cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
783
784
  cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
@@ -1596,7 +1596,6 @@ struct CraftWebView: UIViewRepresentable {
1596
1596
  private var recordingURL: URL?
1597
1597
 
1598
1598
  // Health
1599
- private var healthStore: HKHealthStore?
1600
1599
 
1601
1600
  // SQLite database
1602
1601
  private var db: OpaquePointer?
@@ -1628,9 +1627,6 @@ struct CraftWebView: UIViewRepresentable {
1628
1627
  if config.enableMotionSensors {
1629
1628
  motionManager = CMMotionManager()
1630
1629
  }
1631
- if config.enableHealthKit && HKHealthStore.isHealthDataAvailable() {
1632
- healthStore = HKHealthStore()
1633
- }
1634
1630
  if config.enableWatchApp {
1635
1631
  setupWatchConnectivity()
1636
1632
  }
@@ -6900,504 +6896,40 @@ struct CraftWebView: UIViewRepresentable {
6900
6896
  }
6901
6897
 
6902
6898
  // MARK: - Health
6903
- private func requestHealthAuthorization(types: [String], readOnly: Bool = false, callbackId: String?) {
6904
- guard let healthStore = healthStore else {
6905
- rejectCallback(callbackId, error: "HealthKit not available")
6906
- return
6907
- }
6908
-
6909
- var readTypes: Set<HKObjectType> = []
6910
- var shareTypes: Set<HKSampleType> = [HKObjectType.workoutType()]
6911
- if let routeType = HKSeriesType.workoutRoute() as? HKSampleType {
6912
- shareTypes.insert(routeType)
6913
- }
6914
-
6915
- for type in types {
6916
- switch type {
6917
- case "steps":
6918
- if let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount) {
6919
- readTypes.insert(stepType)
6920
- }
6921
- case "heartRate":
6922
- if let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) {
6923
- readTypes.insert(heartType)
6924
- }
6925
- case "activeEnergy":
6926
- if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) {
6927
- readTypes.insert(energyType)
6928
- shareTypes.insert(energyType)
6929
- }
6930
- case "distance":
6931
- if let distanceType = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning) {
6932
- readTypes.insert(distanceType)
6933
- shareTypes.insert(distanceType)
6934
- }
6935
- case "workouts":
6936
- readTypes.insert(HKObjectType.workoutType())
6937
- // A workout's own heart rate and energy are read through
6938
- // these; without them every workout comes back bare.
6939
- if let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) {
6940
- readTypes.insert(heartType)
6941
- }
6942
- if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) {
6943
- readTypes.insert(energyType)
6944
- }
6945
- case "restingHeartRate":
6946
- if let restingType = HKQuantityType.quantityType(forIdentifier: .restingHeartRate) {
6947
- readTypes.insert(restingType)
6948
- }
6949
- case "heartRateVariability":
6950
- if let hrvType = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN) {
6951
- readTypes.insert(hrvType)
6952
- }
6953
- case "bodyMass":
6954
- if let massType = HKQuantityType.quantityType(forIdentifier: .bodyMass) {
6955
- readTypes.insert(massType)
6956
- }
6957
- case "sleep":
6958
- if let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) {
6959
- readTypes.insert(sleepType)
6960
- }
6961
- default:
6962
- break
6963
- }
6899
+ // HealthKit itself is CraftHealth.swift, shared with native screens.
6900
+ private func answer(_ callbackId: String?, _ result: Result<Any, CraftHealthError>) {
6901
+ switch result {
6902
+ case .success(let value): resolveCallback(callbackId, result: value)
6903
+ case .failure(let failure): rejectCallback(callbackId, error: failure.message, code: failure.code)
6964
6904
  }
6905
+ }
6965
6906
 
6966
- // A page that only reads asks only to read: the sheet then says
6967
- // "access", not "access and update".
6968
- if readOnly { shareTypes.removeAll() }
6969
- healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { [weak self] success, error in
6970
- if success {
6971
- self?.resolveCallback(callbackId, result: true)
6972
- } else {
6973
- self?.rejectCallback(callbackId, error: error?.localizedDescription ?? "Authorization failed")
6974
- }
6975
- }
6907
+ private func requestHealthAuthorization(types: [String], readOnly: Bool = false, callbackId: String?) {
6908
+ CraftHealth.shared.requestAuthorization(types: types, readOnly: readOnly) { [weak self] in self?.answer(callbackId, $0) }
6976
6909
  }
6977
6910
 
6978
6911
  private func getHealthData(type: String, startDate: Double?, endDate: Double?, callbackId: String?) {
6979
- guard let healthStore = healthStore else {
6980
- rejectCallback(callbackId, error: "HealthKit not available")
6981
- return
6982
- }
6983
-
6984
- guard let spec = Self.healthQuantity(type), let qType = spec.type else {
6985
- rejectCallback(callbackId, error: "Unknown health data type")
6986
- return
6987
- }
6988
- let qUnit = spec.unit
6989
-
6990
- let start = startDate != nil ? Date(timeIntervalSince1970: startDate! / 1000) : Calendar.current.date(byAdding: .day, value: -7, to: Date())!
6991
- let end = endDate != nil ? Date(timeIntervalSince1970: endDate! / 1000) : Date()
6992
-
6993
- let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate)
6994
-
6995
- // A discrete type (heart rate, weight) has no sum: HealthKit fails
6996
- // the whole query when asked for one, which is what heart rate
6997
- // used to do. Each type is asked for the statistic it has.
6998
- let query = HKStatisticsQuery(quantityType: qType, quantitySamplePredicate: predicate, options: spec.options) { [weak self] _, result, error in
6999
- if let error = error {
7000
- // No samples in the range is an answer, not a failure.
7001
- if (error as? HKError)?.code == .errorNoData {
7002
- self?.resolveCallback(callbackId, result: ["value": 0, "unit": qUnit.unitString])
7003
- return
7004
- }
7005
- self?.rejectCallback(callbackId, error: error.localizedDescription)
7006
- return
7007
- }
7008
-
7009
- let value = result.flatMap { Self.statisticValue($0, options: spec.options, unit: qUnit) } ?? 0
7010
- self?.resolveCallback(callbackId, result: ["value": value, "unit": qUnit.unitString])
7011
- }
7012
-
7013
- healthStore.execute(query)
6912
+ CraftHealth.shared.getData(type: type, startDate: startDate, endDate: endDate) { [weak self] in self?.answer(callbackId, $0) }
7014
6913
  }
7015
6914
 
7016
6915
  private func saveHealthWorkout(body: [String: Any], callbackId: String?) {
7017
- guard let healthStore = healthStore else {
7018
- rejectCallback(callbackId, error: "HealthKit not available")
7019
- return
7020
- }
7021
- guard let activityId = body["activityId"] as? String,
7022
- let type = body["type"] as? String,
7023
- let startValue = body["startDate"] as? Double,
7024
- let endValue = body["endDate"] as? Double,
7025
- endValue > startValue else {
7026
- rejectCallback(callbackId, error: "A valid activityId, type, startDate, and endDate are required")
7027
- return
7028
- }
7029
-
7030
- let activityType: HKWorkoutActivityType
7031
- switch type {
7032
- case "running": activityType = .running
7033
- case "walking": activityType = .walking
7034
- case "hiking": activityType = .hiking
7035
- case "cycling": activityType = .cycling
7036
- default:
7037
- rejectCallback(callbackId, error: "Unsupported workout type")
7038
- return
7039
- }
7040
-
7041
- let distance = (body["distanceMeters"] as? Double).flatMap { value in
7042
- value > 0 ? HKQuantity(unit: .meter(), doubleValue: value) : nil
7043
- }
7044
- let energy = (body["activeEnergyCalories"] as? Double).flatMap { value in
7045
- value > 0 ? HKQuantity(unit: .kilocalorie(), doubleValue: value) : nil
7046
- }
7047
- let start = Date(timeIntervalSince1970: startValue / 1000)
7048
- let end = Date(timeIntervalSince1970: endValue / 1000)
7049
- let workout = HKWorkout(
7050
- activityType: activityType,
7051
- start: start,
7052
- end: end,
7053
- workoutEvents: nil,
7054
- totalEnergyBurned: energy,
7055
- totalDistance: distance,
7056
- metadata: [HKMetadataKeyExternalUUID: activityId, HKMetadataKeyIndoorWorkout: false]
7057
- )
7058
-
7059
- healthStore.save(workout) { [weak self] success, error in
7060
- guard let self = self else { return }
7061
- guard success else {
7062
- self.rejectCallback(callbackId, error: error?.localizedDescription ?? "Workout could not be saved")
7063
- return
7064
- }
7065
-
7066
- let locations = (body["locations"] as? [[String: Any]] ?? []).compactMap { item -> CLLocation? in
7067
- guard let latitude = item["latitude"] as? Double,
7068
- let longitude = item["longitude"] as? Double,
7069
- let timestamp = item["timestamp"] as? Double else { return nil }
7070
- return CLLocation(
7071
- coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude),
7072
- altitude: item["altitude"] as? Double ?? 0,
7073
- horizontalAccuracy: item["accuracy"] as? Double ?? 10,
7074
- verticalAccuracy: -1,
7075
- timestamp: Date(timeIntervalSince1970: timestamp / 1000)
7076
- )
7077
- }.filter { $0.timestamp >= start && $0.timestamp <= end }
7078
-
7079
- guard !locations.isEmpty else {
7080
- self.resolveCallback(callbackId, result: ["id": workout.uuid.uuidString])
7081
- return
7082
- }
7083
-
7084
- let routeBuilder = HKWorkoutRouteBuilder(healthStore: healthStore, device: .local())
7085
- routeBuilder.insertRouteData(locations) { inserted, routeError in
7086
- guard inserted else {
7087
- self.rejectCallback(callbackId, error: routeError?.localizedDescription ?? "Workout route could not be saved")
7088
- return
7089
- }
7090
- routeBuilder.finishRoute(with: workout, metadata: [HKMetadataKeyExternalUUID: activityId]) { route, finishError in
7091
- if let finishError = finishError {
7092
- self.rejectCallback(callbackId, error: finishError.localizedDescription)
7093
- } else {
7094
- self.resolveCallback(callbackId, result: [
7095
- "id": workout.uuid.uuidString,
7096
- "routeId": route?.uuid.uuidString ?? ""
7097
- ])
7098
- }
7099
- }
7100
- }
7101
- }
7102
- }
7103
-
7104
- /// The quantity behind each health data type, its unit, and the one
7105
- /// statistic that type supports.
7106
- private static func healthQuantity(_ type: String) -> (type: HKQuantityType?, unit: HKUnit, options: HKStatisticsOptions)? {
7107
- let bpm = HKUnit.count().unitDivided(by: .minute())
7108
- switch type {
7109
- case "steps": return (HKQuantityType.quantityType(forIdentifier: .stepCount), .count(), .cumulativeSum)
7110
- case "activeEnergy": return (HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned), .kilocalorie(), .cumulativeSum)
7111
- case "distance": return (HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning), .meter(), .cumulativeSum)
7112
- case "heartRate": return (HKQuantityType.quantityType(forIdentifier: .heartRate), bpm, .discreteAverage)
7113
- case "restingHeartRate": return (HKQuantityType.quantityType(forIdentifier: .restingHeartRate), bpm, .discreteAverage)
7114
- case "heartRateVariability": return (HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN), .secondUnit(with: .milli), .discreteAverage)
7115
- case "bodyMass": return (HKQuantityType.quantityType(forIdentifier: .bodyMass), .gramUnit(with: .kilo), .mostRecent)
7116
- default: return nil
7117
- }
7118
- }
7119
-
7120
- private static func statisticValue(_ statistics: HKStatistics, options: HKStatisticsOptions, unit: HKUnit) -> Double? {
7121
- if options.contains(.cumulativeSum) { return statistics.sumQuantity()?.doubleValue(for: unit) }
7122
- if options.contains(.mostRecent) { return statistics.mostRecentQuantity()?.doubleValue(for: unit) }
7123
- return statistics.averageQuantity()?.doubleValue(for: unit)
6916
+ CraftHealth.shared.saveWorkout(body: body) { [weak self] in self?.answer(callbackId, $0) }
7124
6917
  }
7125
6918
 
7126
- private static let healthDayFormatter: DateFormatter = {
7127
- let formatter = DateFormatter()
7128
- formatter.calendar = Calendar(identifier: .gregorian)
7129
- formatter.locale = Locale(identifier: "en_US_POSIX")
7130
- formatter.timeZone = .current
7131
- formatter.dateFormat = "yyyy-MM-dd"
7132
- return formatter
7133
- }()
7134
-
7135
- /// A stable name for a workout's activity, independent of the SDK's
7136
- /// raw values, so a page can map it to its own sports.
7137
- private static func workoutTypeName(_ type: HKWorkoutActivityType) -> String {
7138
- switch type {
7139
- case .running: return "running"
7140
- case .cycling: return "cycling"
7141
- case .walking: return "walking"
7142
- case .hiking: return "hiking"
7143
- case .swimming: return "swimming"
7144
- case .rowing: return "rowing"
7145
- case .elliptical: return "elliptical"
7146
- case .stairClimbing, .stairs: return "stairClimbing"
7147
- case .yoga: return "yoga"
7148
- case .pilates: return "pilates"
7149
- case .functionalStrengthTraining, .traditionalStrengthTraining, .coreTraining: return "strength"
7150
- case .highIntensityIntervalTraining: return "hiit"
7151
- case .crossTraining, .mixedCardio: return "crossTraining"
7152
- case .crossCountrySkiing: return "crossCountrySkiing"
7153
- case .downhillSkiing, .snowboarding: return "skiing"
7154
- case .paddleSports: return "paddling"
7155
- case .climbing: return "climbing"
7156
- case .dance, .socialDance, .cardioDance: return "dance"
7157
- case .cooldown, .flexibility, .mindAndBody: return "mobility"
7158
- default: return "other"
7159
- }
7160
- }
7161
-
7162
- /// Workouts in Apple Health — the watch's, and every app's that
7163
- /// writes there — newest first, with the numbers a training log needs.
7164
6919
  private func getHealthWorkouts(startDate: Double?, endDate: Double?, limit: Int?, callbackId: String?) {
7165
- guard let healthStore = healthStore else {
7166
- rejectCallback(callbackId, error: "HealthKit not available")
7167
- return
7168
- }
7169
-
7170
- let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -30, to: Date())!
7171
- let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
7172
- let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
7173
- let newestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
7174
- let cap = max(1, min(limit ?? 200, 1000))
7175
-
7176
- let query = HKSampleQuery(sampleType: HKObjectType.workoutType(), predicate: predicate, limit: cap, sortDescriptors: [newestFirst]) { [weak self] _, samples, error in
7177
- if let error = error, (error as? HKError)?.code != .errorNoData {
7178
- self?.rejectCallback(callbackId, error: error.localizedDescription)
7179
- return
7180
- }
7181
-
7182
- let bpm = HKUnit.count().unitDivided(by: .minute())
7183
- let workouts: [[String: Any]] = (samples as? [HKWorkout] ?? []).map { workout in
7184
- var item: [String: Any] = [
7185
- "id": workout.uuid.uuidString,
7186
- "type": Self.workoutTypeName(workout.workoutActivityType),
7187
- "startDate": workout.startDate.timeIntervalSince1970 * 1000,
7188
- "endDate": workout.endDate.timeIntervalSince1970 * 1000,
7189
- "durationSeconds": workout.duration,
7190
- "sourceName": workout.sourceRevision.source.name,
7191
- "indoor": (workout.metadata?[HKMetadataKeyIndoorWorkout] as? Bool) ?? false,
7192
- ]
7193
- if let meters = workout.totalDistance?.doubleValue(for: .meter()), meters > 0 {
7194
- item["distanceMeters"] = meters
7195
- }
7196
- if let ascent = workout.metadata?[HKMetadataKeyElevationAscended] as? HKQuantity {
7197
- item["elevationGainMeters"] = ascent.doubleValue(for: .meter())
7198
- }
7199
- if #available(iOS 16.0, *) {
7200
- if let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate),
7201
- let heart = workout.statistics(for: heartType) {
7202
- if let average = heart.averageQuantity()?.doubleValue(for: bpm) { item["averageHeartRate"] = average }
7203
- if let maximum = heart.maximumQuantity()?.doubleValue(for: bpm) { item["maxHeartRate"] = maximum }
7204
- }
7205
- if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned),
7206
- let calories = workout.statistics(for: energyType)?.sumQuantity()?.doubleValue(for: .kilocalorie()) {
7207
- item["activeEnergyCalories"] = calories
7208
- }
7209
- } else if let calories = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) {
7210
- item["activeEnergyCalories"] = calories
7211
- }
7212
- return item
7213
- }
7214
- self?.resolveCallback(callbackId, result: workouts)
7215
- }
7216
- healthStore.execute(query)
6920
+ CraftHealth.shared.getWorkouts(startDate: startDate, endDate: endDate, limit: limit) { [weak self] in self?.answer(callbackId, $0) }
7217
6921
  }
7218
6922
 
7219
- /// One value per local day: a sum for steps, energy and distance, an
7220
- /// average for heart rate, resting heart rate and HRV, the latest
7221
- /// weight, and hours asleep for sleep (counted on the day you woke).
7222
6923
  private func getHealthDailyStatistics(type: String, startDate: Double?, endDate: Double?, callbackId: String?) {
7223
- guard let healthStore = healthStore else {
7224
- rejectCallback(callbackId, error: "HealthKit not available")
7225
- return
7226
- }
7227
-
7228
- let calendar = Calendar.current
7229
- let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
7230
- let start = calendar.startOfDay(for: startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? calendar.date(byAdding: .day, value: -30, to: end)!)
7231
-
7232
- if type == "sleep" {
7233
- guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
7234
- rejectCallback(callbackId, error: "Sleep data is unavailable")
7235
- return
7236
- }
7237
- // From the evening before the first day, so its night counts.
7238
- let predicate = HKQuery.predicateForSamples(withStart: calendar.date(byAdding: .hour, value: -12, to: start), end: end, options: [])
7239
- let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { [weak self] _, samples, error in
7240
- if let error = error, (error as? HKError)?.code != .errorNoData {
7241
- self?.rejectCallback(callbackId, error: error.localizedDescription)
7242
- return
7243
- }
7244
- // Time in bed and time awake are not sleep; every asleep
7245
- // stage (and the older single "asleep" value) is.
7246
- let awake: Set<Int> = [HKCategoryValueSleepAnalysis.inBed.rawValue, HKCategoryValueSleepAnalysis.awake.rawValue]
7247
- var secondsByDay: [String: Double] = [:]
7248
- for sample in samples as? [HKCategorySample] ?? [] where !awake.contains(sample.value) {
7249
- let day = Self.healthDayFormatter.string(from: sample.endDate)
7250
- secondsByDay[day, default: 0] += sample.endDate.timeIntervalSince(sample.startDate)
7251
- }
7252
- let days = secondsByDay.keys.sorted().map { day -> [String: Any] in
7253
- ["date": day, "value": (secondsByDay[day]! / 3600 * 100).rounded() / 100, "unit": "hr"]
7254
- }
7255
- self?.resolveCallback(callbackId, result: days)
7256
- }
7257
- healthStore.execute(query)
7258
- return
7259
- }
7260
-
7261
- guard let spec = Self.healthQuantity(type), let qType = spec.type else {
7262
- rejectCallback(callbackId, error: "Unknown health data type")
7263
- return
7264
- }
7265
- let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
7266
- let query = HKStatisticsCollectionQuery(
7267
- quantityType: qType,
7268
- quantitySamplePredicate: predicate,
7269
- options: spec.options,
7270
- anchorDate: start,
7271
- intervalComponents: DateComponents(day: 1)
7272
- )
7273
- query.initialResultsHandler = { [weak self] _, collection, error in
7274
- if let error = error, (error as? HKError)?.code != .errorNoData {
7275
- self?.rejectCallback(callbackId, error: error.localizedDescription)
7276
- return
7277
- }
7278
- var days: [[String: Any]] = []
7279
- collection?.enumerateStatistics(from: start, to: end) { statistics, _ in
7280
- guard let value = Self.statisticValue(statistics, options: spec.options, unit: spec.unit) else { return }
7281
- days.append([
7282
- "date": Self.healthDayFormatter.string(from: statistics.startDate),
7283
- "value": value,
7284
- "unit": spec.unit.unitString,
7285
- ])
7286
- }
7287
- self?.resolveCallback(callbackId, result: days)
7288
- }
7289
- healthStore.execute(query)
6924
+ CraftHealth.shared.getDailyStatistics(type: type, startDate: startDate, endDate: endDate) { [weak self] in self?.answer(callbackId, $0) }
7290
6925
  }
7291
6926
 
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
6927
  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
- }
6928
+ CraftHealth.shared.getHeartRateSamples(startDate: startDate, endDate: endDate, limit: limit) { [weak self] in self?.answer(callbackId, $0) }
7358
6929
  }
7359
6930
 
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
6931
  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)
6932
+ CraftHealth.shared.getSleepIntervals(startDate: startDate, endDate: endDate) { [weak self] in self?.answer(callbackId, $0) }
7401
6933
  }
7402
6934
 
7403
6935
  // MARK: - Live Activities