@craft-native/ios 0.0.140 → 0.0.142
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 +1 -1
- package/dist/cli.js +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +14 -482
- package/templates/CraftHealth.swift +551 -0
- package/templates/CraftNativeActions.swift +11 -0
- package/templates/CraftNativeScreen.swift +798 -62
package/README.md
CHANGED
|
@@ -115,7 +115,7 @@ In Xcode:
|
|
|
115
115
|
Keyboard frame changes update the native controller's additional safe area, so
|
|
116
116
|
focused inputs and responsive `onLayout` measurements move with the keyboard.
|
|
117
117
|
|
|
118
|
-
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `Text`, `Button`, `Link`, `TextInput`, `Picker`, `Switch`, `Slider`, `ActivityIndicator`, `Image`, `ScrollView`, and recycling `FlatList` nodes as UIKit controls. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
|
|
118
|
+
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `Text`, `Button`, `Link`, `TextInput`, `Picker`, `Switch`, `Slider`, `ActivityIndicator`, `Image`, `Video`, `ScrollView`, and recycling `FlatList` nodes as UIKit controls. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
|
|
119
119
|
|
|
120
120
|
```bash
|
|
121
121
|
craft ios init NativeDemo --renderer native --output ./ios-native
|
package/dist/cli.js
CHANGED
|
@@ -723,6 +723,7 @@ async function init(options) {
|
|
|
723
723
|
cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
|
|
724
724
|
cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
|
|
725
725
|
cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
|
|
726
|
+
cpSync(join(TEMPLATES_DIR, "CraftHealth.swift"), join(output, "Sources", "CraftHealth.swift"));
|
|
726
727
|
cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
|
|
727
728
|
cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
|
|
728
729
|
cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
|
package/dist/index.js
CHANGED
|
@@ -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"));
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -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
|
-
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
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
|
-
|
|
6967
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|