Skip to content

Commit 90813d2

Browse files
author
Ivan Mikhailovskii
committed
feat: add workout types
1 parent 5282a25 commit 90813d2

8 files changed

Lines changed: 486 additions & 7 deletions

ios/FitnessDataType.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ enum FitnessDataType: String {
2222
case stepCount = "HKQuantityTypeIdentifierStepCount"
2323
case swimmingStrokeCount = "HKQuantityTypeIdentifierSwimmingStrokeCount"
2424
case underwaterDepth = "HKQuantityTypeIdentifieUnderwaterDepth"
25+
case heartRate = "HKQuantityTypeIdentifierHeartRate"
2526

2627
func defaultUnit() -> String {
2728
if self == .distanceWalkingRunning {

ios/HKSample+Extensions.swift

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import HealthKit
22

33
extension HKSample {
44

5-
func convertToDictionary() -> [String: Any]? {
5+
func convertToDictionary(
6+
healthStore: HKHealthStore
7+
) async -> [String: Any]? {
68
guard let workout: HKWorkout = self as? HKWorkout else {
79
return nil
810
}
@@ -13,6 +15,11 @@ extension HKSample {
1315
let isoStartDate = self.formatUtcIsoDateTimeString(workout.startDate)
1416
let isoEndDate = self.formatUtcIsoDateTimeString(workout.endDate)
1517

18+
let analysisHeartRate = try? await analyzeWorkoutHeartRate(
19+
workout: workout,
20+
healthStore: healthStore
21+
)
22+
1623
var dataRecords = [
1724
"uuid": workout.uuid.uuidString as Any,
1825
"duration": workout.duration as Any,
@@ -21,6 +28,9 @@ extension HKSample {
2128
"distance": distance as Any,
2229
"type": workout.workoutActivityType.rawValue as Any,
2330
"metadata": workout.metadata as Any,
31+
"heartRateAverage": analysisHeartRate?.averageBPM as Any,
32+
"heartRateMin": analysisHeartRate?.minBPM as Any,
33+
"heartRateMax": analysisHeartRate?.maxBPM as Any,
2434
"source": [
2535
"name": workout.sourceRevision.source.name as Any,
2636
"device": sourceDevice as Any,
@@ -40,3 +50,145 @@ extension HKSample {
4050
}
4151
}
4252

53+
@available(iOS 15.0, *)
54+
func analyzeWorkoutHeartRate(
55+
workout: HKWorkout,
56+
healthStore: HKHealthStore
57+
) async throws -> WorkoutHeartRateAnalysis {
58+
async let average = workout.averageHeartRate(healthStore: healthStore)
59+
async let max = workout.maxHeartRate(healthStore: healthStore)
60+
async let min = workout.minHeartRate(healthStore: healthStore)
61+
async let samples = workout.heartRateSamples(healthStore: healthStore)
62+
63+
return try await WorkoutHeartRateAnalysis(
64+
averageBPM: average,
65+
maxBPM: max,
66+
minBPM: min,
67+
samples: samples
68+
)
69+
}
70+
71+
// MARK: - Data Model
72+
73+
struct WorkoutHeartRateAnalysis {
74+
let averageBPM: Double?
75+
let maxBPM: Double?
76+
let minBPM: Double?
77+
let samples: [HeartRateSample]
78+
79+
struct HeartRateSample {
80+
let value: Double
81+
let timestamp: Date
82+
}
83+
}
84+
85+
@available(iOS 15.0, *)
86+
extension HKWorkout {
87+
88+
func averageHeartRate(
89+
healthStore: HKHealthStore
90+
) async throws -> Double? {
91+
try await getHeartRateStatistic(
92+
healthStore: healthStore,
93+
options: .discreteAverage
94+
)
95+
}
96+
97+
func maxHeartRate(
98+
healthStore: HKHealthStore
99+
) async throws -> Double? {
100+
try await getHeartRateStatistic(
101+
healthStore: healthStore,
102+
options: .discreteMax
103+
)
104+
}
105+
106+
func minHeartRate(
107+
healthStore: HKHealthStore
108+
) async throws -> Double? {
109+
try await getHeartRateStatistic(
110+
healthStore: healthStore,
111+
options: .discreteMin
112+
)
113+
}
114+
115+
func heartRateSamples(
116+
healthStore: HKHealthStore
117+
) async throws -> [WorkoutHeartRateAnalysis.HeartRateSample] {
118+
guard let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate) else {
119+
throw HKError(.errorInvalidArgument)
120+
}
121+
122+
let predicate = HKQuery.predicateForSamples(
123+
withStart: startDate,
124+
end: endDate,
125+
options: .strictStartDate
126+
)
127+
128+
let samples: [HKQuantitySample] = try await withCheckedThrowingContinuation { continuation in
129+
let query = HKSampleQuery(
130+
sampleType: heartRateType,
131+
predicate: predicate,
132+
limit: HKObjectQueryNoLimit,
133+
sortDescriptors: [.init(key: HKSampleSortIdentifierStartDate, ascending: true)]
134+
) { _, samples, error in
135+
if let error = error {
136+
continuation.resume(throwing: error)
137+
} else {
138+
continuation.resume(returning: (samples as? [HKQuantitySample]) ?? [])
139+
}
140+
}
141+
healthStore.execute(query)
142+
}
143+
144+
return samples.map {
145+
WorkoutHeartRateAnalysis.HeartRateSample(
146+
value: $0.quantity.doubleValue(for: .heartRateUnit),
147+
timestamp: $0.startDate
148+
)
149+
}
150+
}
151+
152+
private func getHeartRateStatistic(
153+
healthStore: HKHealthStore,
154+
options: HKStatisticsOptions
155+
) async throws -> Double? {
156+
guard let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate) else {
157+
throw HKError(.errorInvalidArgument)
158+
}
159+
160+
let stats: HKStatistics = try await withCheckedThrowingContinuation { continuation in
161+
let query = HKStatisticsQuery(
162+
quantityType: heartRateType,
163+
quantitySamplePredicate: HKQuery.predicateForSamples(
164+
withStart: startDate,
165+
end: endDate,
166+
options: .strictStartDate
167+
),
168+
options: options
169+
) { _, statistics, error in
170+
if let error = error {
171+
continuation.resume(throwing: error)
172+
} else if let stats = statistics {
173+
continuation.resume(returning: stats)
174+
} else {
175+
continuation.resume(throwing: HKError(.errorNoData))
176+
}
177+
}
178+
healthStore.execute(query)
179+
}
180+
181+
switch options {
182+
case .discreteAverage: return stats.averageQuantity()?.doubleValue(for: .heartRateUnit)
183+
case .discreteMax: return stats.maximumQuantity()?.doubleValue(for: .heartRateUnit)
184+
case .discreteMin: return stats.minimumQuantity()?.doubleValue(for: .heartRateUnit)
185+
default: return nil
186+
}
187+
}
188+
}
189+
190+
extension HKUnit {
191+
static var heartRateUnit: HKUnit {
192+
HKUnit(from: "count/min")
193+
}
194+
}

ios/HKTypeData.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ enum HKTypeData: String {
2323
case appleStandTime = "HKQuantityTypeIdentifierAppleStandTime"
2424
case VO2Max = "HKQuantityTypeIdentifierVO2Max"
2525
case lowCardioFitnessEvent = "HKCategoryTypeIdentifierLowCardioFitnessEvent"
26+
case heartRate = "HKQuantityTypeIdentifierHeartRate"
2627
}

ios/SQFitnessStatistic.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ public class SQFitnessStatistic: NSObject {
2121
.workout,
2222
.stepCount,
2323
.distanceCycling,
24-
.distanceWalkingRunning
24+
.distanceWalkingRunning,
25+
.heartRate
2526
],
2627
complete: { resolve(true) },
2728
handleError: { error in

ios/SQHealthKitManager.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,19 @@ class SQHealthKitManager {
7777
return
7878
}
7979

80-
let dataRecords: [Dictionary] = samples?.compactMap {
81-
$0.convertToDictionary()
82-
} ?? []
80+
Task {
81+
var dataRecords = [Dictionary<String, Any>]()
82+
83+
for sample in samples ?? [] {
84+
guard let workout = await sample.convertToDictionary(
85+
healthStore: self.healthStore
86+
) else { continue }
87+
88+
dataRecords.append(workout)
89+
}
8390

8491
complete(dataRecords)
92+
}
8593
}
8694

8795
self.healthStore.execute(query)

0 commit comments

Comments
 (0)