@appeeky/expo-healthkit 0.1.0

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +92 -0
  3. package/README.md +453 -0
  4. package/android/build.gradle +22 -0
  5. package/android/src/main/AndroidManifest.xml +74 -0
  6. package/android/src/main/java/expo/modules/healthkit/ExpoHealthKitModule.kt +228 -0
  7. package/android/src/main/java/expo/modules/healthkit/HealthConnectMapping.kt +129 -0
  8. package/android/src/main/java/expo/modules/healthkit/HealthConnectNutrition.kt +152 -0
  9. package/android/src/main/java/expo/modules/healthkit/HealthConnectRationaleActivity.kt +62 -0
  10. package/android/src/main/java/expo/modules/healthkit/HealthConnectService.kt +1054 -0
  11. package/android/src/main/java/expo/modules/healthkit/HealthConnectUnits.kt +133 -0
  12. package/android/src/main/java/expo/modules/healthkit/HealthConnectWorkouts.kt +105 -0
  13. package/android/src/main/java/expo/modules/healthkit/HealthKitExceptions.kt +30 -0
  14. package/app.plugin.js +1 -0
  15. package/build/ExpoHealthKitModule.d.ts +39 -0
  16. package/build/ExpoHealthKitModule.d.ts.map +1 -0
  17. package/build/ExpoHealthKitModule.js +35 -0
  18. package/build/ExpoHealthKitModule.js.map +1 -0
  19. package/build/ExpoHealthKitModule.web.d.ts +39 -0
  20. package/build/ExpoHealthKitModule.web.d.ts.map +1 -0
  21. package/build/ExpoHealthKitModule.web.js +105 -0
  22. package/build/ExpoHealthKitModule.web.js.map +1 -0
  23. package/build/dates.d.ts +6 -0
  24. package/build/dates.d.ts.map +1 -0
  25. package/build/dates.js +13 -0
  26. package/build/dates.js.map +1 -0
  27. package/build/errors.d.ts +9 -0
  28. package/build/errors.d.ts.map +1 -0
  29. package/build/errors.js +18 -0
  30. package/build/errors.js.map +1 -0
  31. package/build/identifiers.d.ts +418 -0
  32. package/build/identifiers.d.ts.map +1 -0
  33. package/build/identifiers.js +402 -0
  34. package/build/identifiers.js.map +1 -0
  35. package/build/index.d.ts +454 -0
  36. package/build/index.d.ts.map +1 -0
  37. package/build/index.js +418 -0
  38. package/build/index.js.map +1 -0
  39. package/build/types.d.ts +542 -0
  40. package/build/types.d.ts.map +1 -0
  41. package/build/types.js +2 -0
  42. package/build/types.js.map +1 -0
  43. package/docs/banner.jpg +0 -0
  44. package/expo-module.config.json +9 -0
  45. package/ios/ExpoHealthKit.podspec +30 -0
  46. package/ios/ExpoHealthKitModule.swift +155 -0
  47. package/ios/HealthKitAdvancedQueries.swift +458 -0
  48. package/ios/HealthKitExceptions.swift +109 -0
  49. package/ios/HealthKitIdentifiers.swift +183 -0
  50. package/ios/HealthKitRecords.swift +214 -0
  51. package/ios/HealthKitService.swift +626 -0
  52. package/ios/PrivacyInfo.xcprivacy +14 -0
  53. package/package.json +105 -0
  54. package/plugin/build/index.d.ts +30 -0
  55. package/plugin/build/index.js +138 -0
  56. package/src/ExpoHealthKitModule.ts +116 -0
  57. package/src/ExpoHealthKitModule.web.ts +140 -0
  58. package/src/dates.ts +17 -0
  59. package/src/errors.ts +22 -0
  60. package/src/identifiers.ts +494 -0
  61. package/src/index.ts +563 -0
  62. package/src/types.ts +625 -0
@@ -0,0 +1,155 @@
1
+ import ExpoModulesCore
2
+
3
+ public class ExpoHealthKitModule: Module {
4
+ private let service = HealthKitService()
5
+
6
+ public func definition() -> ModuleDefinition {
7
+ Name("ExpoHealthKit")
8
+
9
+ Events("onUpdate")
10
+
11
+ OnCreate {
12
+ self.service.onUpdate = { type in
13
+ self.sendEvent("onUpdate", [
14
+ "type": type
15
+ ])
16
+ }
17
+ }
18
+
19
+ OnDestroy {
20
+ self.service.stopObserving()
21
+ }
22
+
23
+ Function("isHealthDataAvailable") {
24
+ self.service.isAvailable()
25
+ }
26
+
27
+ AsyncFunction("requestAuthorization") { (options: AuthorizationOptions) in
28
+ try await self.service.requestAuthorization(options)
29
+ }
30
+
31
+ AsyncFunction("getAuthorizationStatus") { (identifier: String) in
32
+ try self.service.authorizationStatus(for: identifier)
33
+ }
34
+
35
+ AsyncFunction("getRequestStatusForAuthorization") { (options: AuthorizationOptions) in
36
+ try await self.service.requestStatusForAuthorization(options)
37
+ }
38
+
39
+ AsyncFunction("queryQuantitySamples") { (options: QuantityQueryOptions) in
40
+ try await self.service.queryQuantitySamples(options)
41
+ }
42
+
43
+ AsyncFunction("queryCategorySamples") { (options: CategoryQueryOptions) in
44
+ try await self.service.queryCategorySamples(options)
45
+ }
46
+
47
+ AsyncFunction("queryWorkouts") { (options: WorkoutQueryOptions) in
48
+ try await self.service.queryWorkouts(options)
49
+ }
50
+
51
+ AsyncFunction("queryElectrocardiograms") { (options: ElectrocardiogramQueryOptions) in
52
+ try await self.service.queryElectrocardiograms(options)
53
+ }
54
+
55
+ AsyncFunction("queryActivitySummaries") { (options: ActivitySummaryQueryOptions) in
56
+ try await self.service.queryActivitySummaries(options)
57
+ }
58
+
59
+ AsyncFunction("queryClinicalRecords") { (options: ClinicalRecordQueryOptions) in
60
+ try await self.service.queryClinicalRecords(options)
61
+ }
62
+
63
+ AsyncFunction("queryAudiograms") { (options: DateRangeQueryOptions) in
64
+ try await self.service.queryAudiograms(options)
65
+ }
66
+
67
+ AsyncFunction("queryWorkoutRoute") { (options: WorkoutRouteQueryOptions) in
68
+ try await self.service.queryWorkoutRoute(options)
69
+ }
70
+
71
+ AsyncFunction("queryCorrelations") { (options: CorrelationQueryOptions) in
72
+ try await self.service.queryCorrelations(options)
73
+ }
74
+
75
+ AsyncFunction("saveCorrelation") { (input: CorrelationInput) in
76
+ try await self.service.saveCorrelation(input)
77
+ }
78
+
79
+ AsyncFunction("queryHeartbeatSeries") { (options: HeartbeatSeriesQueryOptions) in
80
+ try await self.service.queryHeartbeatSeries(options)
81
+ }
82
+
83
+ AsyncFunction("queryStatistics") { (options: StatisticsQueryOptions) in
84
+ try await self.service.queryStatistics(options)
85
+ }
86
+
87
+ AsyncFunction("queryStatisticsCollection") { (options: StatisticsCollectionQueryOptions) in
88
+ try await self.service.queryStatisticsCollection(options)
89
+ }
90
+
91
+ AsyncFunction("queryAnchored") { (options: AnchoredQueryOptions) in
92
+ try await self.service.queryAnchored(options)
93
+ }
94
+
95
+ AsyncFunction("saveQuantitySample") { (sample: QuantitySampleInput) in
96
+ try await self.service.saveQuantitySample(sample)
97
+ }
98
+
99
+ AsyncFunction("saveCategorySample") { (sample: CategorySampleInput) in
100
+ try await self.service.saveCategorySample(sample)
101
+ }
102
+
103
+ AsyncFunction("saveWorkout") { (workout: WorkoutInput) in
104
+ try await self.service.saveWorkout(workout)
105
+ }
106
+
107
+ AsyncFunction("deleteObjects") { (options: DeleteObjectsOptions) in
108
+ try await self.service.deleteObjects(options)
109
+ }
110
+
111
+ AsyncFunction("getBiologicalSex") {
112
+ try self.service.biologicalSex()
113
+ }
114
+
115
+ AsyncFunction("getBloodType") {
116
+ try self.service.bloodType()
117
+ }
118
+
119
+ AsyncFunction("getDateOfBirth") {
120
+ try self.service.dateOfBirth()
121
+ }
122
+
123
+ AsyncFunction("getFitzpatrickSkinType") {
124
+ try self.service.fitzpatrickSkinType()
125
+ }
126
+
127
+ AsyncFunction("getWheelchairUse") {
128
+ try self.service.wheelchairUse()
129
+ }
130
+
131
+ AsyncFunction("enableBackgroundDelivery") { (type: String, frequency: Int) in
132
+ try await self.service.enableBackgroundDelivery(type: type, frequency: frequency)
133
+ }
134
+
135
+ AsyncFunction("disableBackgroundDelivery") { (type: String) in
136
+ try await self.service.disableBackgroundDelivery(type: type)
137
+ }
138
+
139
+ AsyncFunction("disableAllBackgroundDelivery") {
140
+ try await self.service.disableAllBackgroundDelivery()
141
+ }
142
+
143
+ AsyncFunction("observeTypes") { (types: [String]) in
144
+ try self.service.startObserving(types)
145
+ }
146
+
147
+ AsyncFunction("clearObserverQueries") {
148
+ self.service.stopObserving()
149
+ }
150
+
151
+ OnStopObserving("onUpdate") {
152
+ self.service.stopObserving()
153
+ }
154
+ }
155
+ }
@@ -0,0 +1,458 @@
1
+ import CoreLocation
2
+ import HealthKit
3
+
4
+ extension HealthKitService {
5
+ func queryElectrocardiograms(_ options: ElectrocardiogramQueryOptions) async throws -> [[String: Any]] {
6
+ try ensureAvailable()
7
+ let samples = try await sampleQuery(
8
+ sampleType: HKObjectType.electrocardiogramType(),
9
+ from: options.from,
10
+ to: options.to,
11
+ limit: options.limit,
12
+ ascending: options.ascending
13
+ )
14
+
15
+ var records: [[String: Any]] = []
16
+ for case let ecg as HKElectrocardiogram in samples {
17
+ var record = mapElectrocardiogram(ecg)
18
+ if options.includeVoltage {
19
+ record["voltageMeasurements"] = try await voltageMeasurements(for: ecg)
20
+ }
21
+ records.append(record)
22
+ }
23
+ return records
24
+ }
25
+
26
+ func queryAudiograms(_ options: DateRangeQueryOptions) async throws -> [[String: Any]] {
27
+ try ensureAvailable()
28
+ let samples = try await sampleQuery(
29
+ sampleType: HKObjectType.audiogramSampleType(),
30
+ from: options.from,
31
+ to: options.to,
32
+ limit: options.limit,
33
+ ascending: options.ascending
34
+ )
35
+ return samples.compactMap { sample in
36
+ guard let audiogram = sample as? HKAudiogramSample else {
37
+ return nil
38
+ }
39
+ return mapAudiogram(audiogram)
40
+ }
41
+ }
42
+
43
+ func queryActivitySummaries(_ options: ActivitySummaryQueryOptions) async throws -> [[String: Any]] {
44
+ try ensureAvailable()
45
+ let summaries: [HKActivitySummary] = try await withCheckedThrowingContinuation { continuation in
46
+ do {
47
+ try catchingHealthKit {
48
+ let predicate = try self.activitySummaryPredicate(from: options.from, to: options.to)
49
+ let query = HKActivitySummaryQuery(predicate: predicate) { _, result, error in
50
+ if let error {
51
+ continuation.resume(throwing: error)
52
+ } else {
53
+ continuation.resume(returning: result ?? [])
54
+ }
55
+ }
56
+ self.store.execute(query)
57
+ }
58
+ } catch {
59
+ continuation.resume(throwing: error)
60
+ }
61
+ }
62
+ return summaries.map(mapActivitySummary)
63
+ }
64
+
65
+ func queryClinicalRecords(_ options: ClinicalRecordQueryOptions) async throws -> [[String: Any]] {
66
+ try ensureAvailable()
67
+ let sampleType = try HealthKitIdentifiers.sampleType(for: options.type)
68
+ guard sampleType is HKClinicalType else {
69
+ throw InvalidIdentifierException(options.type)
70
+ }
71
+ let samples = try await sampleQuery(
72
+ sampleType: sampleType,
73
+ from: options.from,
74
+ to: options.to,
75
+ limit: options.limit,
76
+ ascending: options.ascending
77
+ )
78
+ return samples.compactMap { sample in
79
+ guard let record = sample as? HKClinicalRecord else {
80
+ return nil
81
+ }
82
+ return mapClinicalRecord(record)
83
+ }
84
+ }
85
+
86
+ func queryWorkoutRoute(_ options: WorkoutRouteQueryOptions) async throws -> [[String: Any]] {
87
+ try ensureAvailable()
88
+ guard let uuid = UUID(uuidString: options.workoutUUID) else {
89
+ throw MissingUuidException()
90
+ }
91
+
92
+ let workouts = try await executeSampleQuery(
93
+ sampleType: HKObjectType.workoutType(),
94
+ predicate: HKQuery.predicateForObject(with: uuid),
95
+ limit: 1,
96
+ ascending: false
97
+ )
98
+ guard let workout = workouts.first as? HKWorkout else {
99
+ throw SampleNotFoundException(options.workoutUUID)
100
+ }
101
+
102
+ let routes = try await executeSampleQuery(
103
+ sampleType: HKSeriesType.workoutRoute(),
104
+ predicate: HKQuery.predicateForObjects(from: workout),
105
+ limit: HKObjectQueryNoLimit,
106
+ ascending: true
107
+ )
108
+
109
+ var records: [[String: Any]] = []
110
+ for case let route as HKWorkoutRoute in routes {
111
+ let locations = try await routeLocations(for: route)
112
+ records.append(mapWorkoutRoute(route, locations: locations))
113
+ }
114
+ return records
115
+ }
116
+
117
+ func queryCorrelations(_ options: CorrelationQueryOptions) async throws -> [[String: Any]] {
118
+ try ensureAvailable()
119
+ let sampleType = try HealthKitIdentifiers.sampleType(for: options.type)
120
+ guard sampleType is HKCorrelationType else {
121
+ throw InvalidIdentifierException(options.type)
122
+ }
123
+ let samples = try await sampleQuery(
124
+ sampleType: sampleType,
125
+ from: options.from,
126
+ to: options.to,
127
+ limit: options.limit,
128
+ ascending: options.ascending
129
+ )
130
+ return samples.compactMap { sample in
131
+ guard let correlation = sample as? HKCorrelation else {
132
+ return nil
133
+ }
134
+ return mapCorrelation(correlation)
135
+ }
136
+ }
137
+
138
+ func saveCorrelation(_ input: CorrelationInput) async throws -> String {
139
+ try ensureAvailable()
140
+ guard !input.objects.isEmpty else {
141
+ throw EmptyCorrelationException()
142
+ }
143
+ let sampleType = try HealthKitIdentifiers.sampleType(for: input.type)
144
+ guard let correlationType = sampleType as? HKCorrelationType else {
145
+ throw InvalidIdentifierException(input.type)
146
+ }
147
+ let start = try HealthKitIdentifiers.date(from: input.startDate)
148
+ let end = try HealthKitIdentifiers.date(from: input.endDate)
149
+ let objects = Set(try input.objects.map { try makeQuantitySample($0) })
150
+ let correlation = HKCorrelation(
151
+ type: correlationType,
152
+ start: start,
153
+ end: end,
154
+ objects: objects,
155
+ metadata: input.metadata
156
+ )
157
+ try await store.save(correlation)
158
+ return correlation.uuid.uuidString
159
+ }
160
+
161
+ func queryHeartbeatSeries(_ options: HeartbeatSeriesQueryOptions) async throws -> [[String: Any]] {
162
+ try ensureAvailable()
163
+ let samples = try await sampleQuery(
164
+ sampleType: HKSeriesType.heartbeat(),
165
+ from: options.from,
166
+ to: options.to,
167
+ limit: options.limit,
168
+ ascending: options.ascending
169
+ )
170
+
171
+ var records: [[String: Any]] = []
172
+ for case let series as HKHeartbeatSeriesSample in samples {
173
+ var record = mapHeartbeatSeries(series)
174
+ if options.includeBeats {
175
+ record["beats"] = try await heartbeatBeats(for: series)
176
+ }
177
+ records.append(record)
178
+ }
179
+ return records
180
+ }
181
+
182
+ private func mapCorrelation(_ correlation: HKCorrelation) -> [String: Any] {
183
+ let objects = correlation.objects.compactMap { object -> [String: Any]? in
184
+ guard let quantity = object as? HKQuantitySample else {
185
+ return nil
186
+ }
187
+ return mapQuantitySample(quantity)
188
+ }
189
+ var record: [String: Any] = [
190
+ "uuid": correlation.uuid.uuidString,
191
+ "type": correlation.correlationType.identifier,
192
+ "startDate": HealthKitIdentifiers.isoString(from: correlation.startDate),
193
+ "endDate": HealthKitIdentifiers.isoString(from: correlation.endDate),
194
+ "objects": objects
195
+ ]
196
+ record["sourceName"] = correlation.sourceRevision.source.name
197
+ record["sourceId"] = correlation.sourceRevision.source.bundleIdentifier
198
+ record["metadata"] = HealthKitIdentifiers.stringifyMetadata(correlation.metadata)
199
+ return record
200
+ }
201
+
202
+ private func mapHeartbeatSeries(_ sample: HKHeartbeatSeriesSample) -> [String: Any] {
203
+ var record: [String: Any] = [
204
+ "uuid": sample.uuid.uuidString,
205
+ "type": "HKDataTypeIdentifierHeartbeatSeries",
206
+ "startDate": HealthKitIdentifiers.isoString(from: sample.startDate),
207
+ "endDate": HealthKitIdentifiers.isoString(from: sample.endDate),
208
+ "count": sample.count
209
+ ]
210
+ record["sourceName"] = sample.sourceRevision.source.name
211
+ record["sourceId"] = sample.sourceRevision.source.bundleIdentifier
212
+ record["metadata"] = HealthKitIdentifiers.stringifyMetadata(sample.metadata)
213
+ return record
214
+ }
215
+
216
+ private func heartbeatBeats(for series: HKHeartbeatSeriesSample) async throws -> [[String: Any]] {
217
+ try await withCheckedThrowingContinuation { continuation in
218
+ var beats: [[String: Any]] = []
219
+ var finished = false
220
+ let query = HKHeartbeatSeriesQuery(heartbeatSeries: series) { _, timeSinceStart, precededByGap, done, error in
221
+ if let error {
222
+ guard !finished else { return }
223
+ finished = true
224
+ continuation.resume(throwing: error)
225
+ return
226
+ }
227
+ beats.append([
228
+ "timeSinceSeriesStart": timeSinceStart,
229
+ "precededByGap": precededByGap
230
+ ])
231
+ if done {
232
+ guard !finished else { return }
233
+ finished = true
234
+ continuation.resume(returning: beats)
235
+ }
236
+ }
237
+ executeQuery(query, continuation: continuation)
238
+ }
239
+ }
240
+
241
+ private func voltageMeasurements(for electrocardiogram: HKElectrocardiogram) async throws -> [[String: Any]] {
242
+ let microvolt = HKUnit.voltUnit(with: .micro)
243
+ return try await withCheckedThrowingContinuation { continuation in
244
+ var measurements: [[String: Any]] = []
245
+ var finished = false
246
+ let query = HKElectrocardiogramQuery(electrocardiogram) { _, result in
247
+ switch result {
248
+ case .error(let error):
249
+ guard !finished else { return }
250
+ finished = true
251
+ continuation.resume(throwing: error)
252
+ case .measurement(let measurement):
253
+ if let quantity = measurement.quantity(for: .appleWatchSimilarToLeadI) {
254
+ measurements.append([
255
+ "timeSinceSampleStart": measurement.timeSinceSampleStart,
256
+ "voltage": quantity.doubleValue(for: microvolt),
257
+ "unit": "uV"
258
+ ])
259
+ }
260
+ case .done:
261
+ guard !finished else { return }
262
+ finished = true
263
+ continuation.resume(returning: measurements)
264
+ @unknown default:
265
+ guard !finished else { return }
266
+ finished = true
267
+ continuation.resume(returning: measurements)
268
+ }
269
+ }
270
+ executeQuery(query, continuation: continuation)
271
+ }
272
+ }
273
+
274
+ private func routeLocations(for route: HKWorkoutRoute) async throws -> [CLLocation] {
275
+ try await withCheckedThrowingContinuation { continuation in
276
+ var locations: [CLLocation] = []
277
+ var finished = false
278
+ let query = HKWorkoutRouteQuery(route: route) { _, batch, done, error in
279
+ if let error {
280
+ guard !finished else { return }
281
+ finished = true
282
+ continuation.resume(throwing: error)
283
+ return
284
+ }
285
+ if let batch {
286
+ locations.append(contentsOf: batch)
287
+ }
288
+ if done {
289
+ guard !finished else { return }
290
+ finished = true
291
+ continuation.resume(returning: locations)
292
+ }
293
+ }
294
+ executeQuery(query, continuation: continuation)
295
+ }
296
+ }
297
+
298
+ private func activitySummaryPredicate(from: String?, to: String?) throws -> NSPredicate? {
299
+ let start = try HealthKitIdentifiers.optionalDate(from: from)
300
+ let end = try HealthKitIdentifiers.optionalDate(from: to)
301
+ guard start != nil || end != nil else {
302
+ return nil
303
+ }
304
+
305
+ var calendar = Calendar.current
306
+ calendar.timeZone = TimeZone.current
307
+
308
+ let startDate = start ?? calendar.startOfDay(for: end ?? Date())
309
+ let endDate = end ?? Date()
310
+ let earlier = min(startDate, endDate)
311
+ let later = max(startDate, endDate)
312
+
313
+ var startComponents = calendar.dateComponents([.era, .year, .month, .day], from: earlier)
314
+ var endComponents = calendar.dateComponents([.era, .year, .month, .day], from: later)
315
+ startComponents.calendar = calendar
316
+ endComponents.calendar = calendar
317
+ startComponents.timeZone = calendar.timeZone
318
+ endComponents.timeZone = calendar.timeZone
319
+ return try catchingHealthKit {
320
+ HKQuery.predicate(forActivitySummariesBetweenStart: startComponents, end: endComponents)
321
+ }
322
+ }
323
+
324
+ private func mapElectrocardiogram(_ sample: HKElectrocardiogram) -> [String: Any] {
325
+ var record: [String: Any] = [
326
+ "uuid": sample.uuid.uuidString,
327
+ "type": "HKElectrocardiogramTypeIdentifier",
328
+ "startDate": HealthKitIdentifiers.isoString(from: sample.startDate),
329
+ "endDate": HealthKitIdentifiers.isoString(from: sample.endDate),
330
+ "classification": Int(sample.classification.rawValue),
331
+ "symptomsStatus": Int(sample.symptomsStatus.rawValue),
332
+ "numberOfVoltageMeasurements": sample.numberOfVoltageMeasurements
333
+ ]
334
+ if let heartRate = sample.averageHeartRate {
335
+ record["averageHeartRate"] = heartRate.doubleValue(for: HKUnit.count().unitDivided(by: .minute()))
336
+ }
337
+ if let frequency = sample.samplingFrequency {
338
+ record["samplingFrequency"] = frequency.doubleValue(for: .hertz())
339
+ }
340
+ record["sourceName"] = sample.sourceRevision.source.name
341
+ record["sourceId"] = sample.sourceRevision.source.bundleIdentifier
342
+ record["metadata"] = HealthKitIdentifiers.stringifyMetadata(sample.metadata)
343
+ return record
344
+ }
345
+
346
+ private func mapAudiogram(_ sample: HKAudiogramSample) -> [String: Any] {
347
+ let hertz = HKUnit.hertz()
348
+ let hearingLevel = HKUnit.decibelHearingLevel()
349
+ let points: [[String: Any]] = sample.sensitivityPoints.map { point in
350
+ var item: [String: Any] = [
351
+ "frequency": point.frequency.doubleValue(for: hertz)
352
+ ]
353
+ var left = point.leftEarSensitivity?.doubleValue(for: hearingLevel)
354
+ var right = point.rightEarSensitivity?.doubleValue(for: hearingLevel)
355
+ if #available(iOS 18.1, *) {
356
+ for test in point.tests {
357
+ let value = test.sensitivity.doubleValue(for: hearingLevel)
358
+ switch test.side {
359
+ case .left:
360
+ left = left ?? value
361
+ case .right:
362
+ right = right ?? value
363
+ @unknown default:
364
+ break
365
+ }
366
+ }
367
+ }
368
+ if let left {
369
+ item["leftEarSensitivity"] = left
370
+ }
371
+ if let right {
372
+ item["rightEarSensitivity"] = right
373
+ }
374
+ return item
375
+ }
376
+
377
+ var record: [String: Any] = [
378
+ "uuid": sample.uuid.uuidString,
379
+ "type": "HKAudiogramSampleTypeIdentifier",
380
+ "startDate": HealthKitIdentifiers.isoString(from: sample.startDate),
381
+ "endDate": HealthKitIdentifiers.isoString(from: sample.endDate),
382
+ "sensitivityPoints": points
383
+ ]
384
+ record["sourceName"] = sample.sourceRevision.source.name
385
+ record["sourceId"] = sample.sourceRevision.source.bundleIdentifier
386
+ record["metadata"] = HealthKitIdentifiers.stringifyMetadata(sample.metadata)
387
+ return record
388
+ }
389
+
390
+ private func mapActivitySummary(_ summary: HKActivitySummary) -> [String: Any] {
391
+ let energy = HKUnit.kilocalorie()
392
+ let minute = HKUnit.minute()
393
+ let count = HKUnit.count()
394
+ let components = summary.dateComponents(for: Calendar.current)
395
+ let date = String(
396
+ format: "%04d-%02d-%02d",
397
+ components.year ?? 0,
398
+ components.month ?? 0,
399
+ components.day ?? 0
400
+ )
401
+
402
+ var record: [String: Any] = [
403
+ "date": date,
404
+ "activeEnergyBurned": summary.activeEnergyBurned.doubleValue(for: energy),
405
+ "appleExerciseTime": summary.appleExerciseTime.doubleValue(for: minute),
406
+ "appleStandHours": summary.appleStandHours.doubleValue(for: count),
407
+ "activeEnergyBurnedGoal": summary.activeEnergyBurnedGoal.doubleValue(for: energy),
408
+ "appleExerciseTimeGoal": summary.appleExerciseTimeGoal.doubleValue(for: minute),
409
+ "appleStandHoursGoal": summary.appleStandHoursGoal.doubleValue(for: count)
410
+ ]
411
+ record["appleMoveTime"] = summary.appleMoveTime.doubleValue(for: minute)
412
+ record["appleMoveTimeGoal"] = summary.appleMoveTimeGoal.doubleValue(for: minute)
413
+ return record
414
+ }
415
+
416
+ private func mapClinicalRecord(_ sample: HKClinicalRecord) -> [String: Any] {
417
+ var record: [String: Any] = [
418
+ "uuid": sample.uuid.uuidString,
419
+ "type": sample.clinicalType.identifier,
420
+ "startDate": HealthKitIdentifiers.isoString(from: sample.startDate),
421
+ "endDate": HealthKitIdentifiers.isoString(from: sample.endDate),
422
+ "displayName": sample.displayName
423
+ ]
424
+ if let fhir = sample.fhirResource {
425
+ record["fhirResourceType"] = fhir.resourceType.rawValue
426
+ record["fhirJson"] = String(data: fhir.data, encoding: .utf8)
427
+ }
428
+ record["sourceName"] = sample.sourceRevision.source.name
429
+ record["sourceId"] = sample.sourceRevision.source.bundleIdentifier
430
+ return record
431
+ }
432
+
433
+ private func mapWorkoutRoute(_ route: HKWorkoutRoute, locations: [CLLocation]) -> [String: Any] {
434
+ [
435
+ "uuid": route.uuid.uuidString,
436
+ "type": "HKWorkoutRouteTypeIdentifier",
437
+ "startDate": HealthKitIdentifiers.isoString(from: route.startDate),
438
+ "endDate": HealthKitIdentifiers.isoString(from: route.endDate),
439
+ "locations": locations.map { location -> [String: Any] in
440
+ var point: [String: Any] = [
441
+ "latitude": location.coordinate.latitude,
442
+ "longitude": location.coordinate.longitude,
443
+ "timestamp": HealthKitIdentifiers.isoString(from: location.timestamp)
444
+ ]
445
+ if location.verticalAccuracy >= 0 {
446
+ point["altitude"] = location.altitude
447
+ }
448
+ if location.speed >= 0 {
449
+ point["speed"] = location.speed
450
+ }
451
+ if location.course >= 0 {
452
+ point["course"] = location.course
453
+ }
454
+ return point
455
+ }
456
+ ]
457
+ }
458
+ }