From acdf66bd849ea6c4c348c574f903dae3fbc1af8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=AB?= Date: Sun, 9 Aug 2026 17:45:03 +0200 Subject: [PATCH] feat(watch): add HealthKit workout tracking with authorization and live data --- targets/watch/Info.plist | 7 ++- targets/watch/WorkoutManager.swift | 92 ++++++++++++++++++++++++++--- targets/watch/expo-target.config.js | 4 +- 3 files changed, 93 insertions(+), 10 deletions(-) diff --git a/targets/watch/Info.plist b/targets/watch/Info.plist index f683276..f095216 100644 --- a/targets/watch/Info.plist +++ b/targets/watch/Info.plist @@ -1,5 +1,10 @@ - + + NSHealthShareUsageDescription + SwimBuddy needs access to your health data to track your swim workouts. + NSHealthUpdateUsageDescription + SwimBuddy needs to save your swim workout data to Health. + \ No newline at end of file diff --git a/targets/watch/WorkoutManager.swift b/targets/watch/WorkoutManager.swift index 997f509..00da603 100644 --- a/targets/watch/WorkoutManager.swift +++ b/targets/watch/WorkoutManager.swift @@ -5,7 +5,7 @@ import WatchKit import WatchConnectivity import CoreMotion -class WorkoutManager: ObservableObject { +class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate { @Published var isWorkoutActive = false @Published var savedWorkouts: [StoredWorkout] = [] @Published var completedWorkouts: [StoredWorkout] = [] @@ -39,9 +39,41 @@ class WorkoutManager: ObservableObject { private var wasStationary = false private var stationarySince: Date? @Published var lapDetected = false + @Published var healthKitAuthorized = false - init() { + override init() { + super.init() healthStore = HKHealthStore() + requestHealthKitAuthorization() + } + + private func requestHealthKitAuthorization() { + guard let healthStore = healthStore else { return } + let typesToRead: Set = [ + HKQuantityType.quantityType(forIdentifier: .heartRate)!, + HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!, + HKObjectType.workoutType() + ] + let typesToWrite: Set = [ + HKObjectType.workoutType() + ] + + if healthStore.authorizationStatus(for: .workoutType()) == .sharingAuthorized { + DispatchQueue.main.async { + self.healthKitAuthorized = true + } + return + } + + healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) { [weak self] success, error in + if let error = error { + print("HealthKit authorization failed: \(error)") + } + DispatchQueue.main.async { + self?.healthKitAuthorized = success + print("HealthKit authorization: \(success)") + } + } } // MARK: - Data Persistence @@ -106,6 +138,12 @@ class WorkoutManager: ObservableObject { func startWorkout() { guard selectedWorkout != nil else { return } + if !healthKitAuthorized { + print("HealthKit not authorized, requesting...") + requestHealthKitAuthorization() + return + } + isWorkoutActive = true currentSetIndex = 0 currentRep = 0 @@ -116,12 +154,14 @@ class WorkoutManager: ObservableObject { wasStationary = false stationarySince = nil - startHealthKitWorkout() + startHealthKitWorkout(poolLength: selectedWorkout?.poolLength ?? 25) startHeartRateQuery() startCalorieQuery() startTimer() startLapDetection() - WKInterfaceDevice.current().enableWaterLock() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + WKInterfaceDevice.current().enableWaterLock() + } WKInterfaceDevice.current().play(.start) } @@ -418,19 +458,26 @@ class WorkoutManager: ObservableObject { // MARK: - HealthKit - private func startHealthKitWorkout() { + private func startHealthKitWorkout(poolLength: Double) { guard let healthStore = healthStore else { return } let config = HKWorkoutConfiguration() config.activityType = .swimming config.locationType = .indoor + config.swimmingLocationType = .pool + config.lapLength = HKQuantity(unit: .meter(), doubleValue: poolLength) do { activeWorkout = try HKWorkoutSession(healthStore: healthStore, configuration: config) - let workoutBuilder = HKWorkoutBuilder(healthStore: healthStore, configuration: config, device: nil) + let workoutBuilder = activeWorkout!.associatedWorkoutBuilder() + workoutBuilder.delegate = self self.builder = workoutBuilder activeWorkout?.startActivity(with: Date()) - workoutBuilder.beginCollection(withStart: Date()) { _, _ in } + workoutBuilder.beginCollection(withStart: Date()) { success, error in + if let error = error { + print("Failed to begin collection: \(error)") + } + } } catch { print("Failed to start HealthKit workout: \(error)") } @@ -438,11 +485,40 @@ class WorkoutManager: ObservableObject { private func endHealthKitWorkout() { activeWorkout?.end() - builder?.endCollection(withEnd: Date()) { [weak self] _, _ in + builder?.endCollection(withEnd: Date()) { [weak self] success, error in + if let error = error { + print("Failed to end collection: \(error)") + } self?.builder?.finishWorkout { _, _ in } } } + // MARK: - HKLiveWorkoutBuilderDelegate + + func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set) { + for type in collectedTypes { + guard let quantityType = type as? HKQuantityType else { continue } + + if let statistics = workoutBuilder.statistics(for: quantityType) { + DispatchQueue.main.async { + switch quantityType { + case HKQuantityType.quantityType(forIdentifier: .heartRate): + let heartRateUnit = HKUnit.count().unitDivided(by: .minute()) + self.heartRate = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) ?? 0 + case HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned): + self.activeCalories = statistics.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0 + default: + break + } + } + } + } + } + + func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) { + // Handle collected events if needed + } + // MARK: - Sync to Phone private func syncCompletedWorkout() { diff --git a/targets/watch/expo-target.config.js b/targets/watch/expo-target.config.js index b16c9e0..a939029 100644 --- a/targets/watch/expo-target.config.js +++ b/targets/watch/expo-target.config.js @@ -4,11 +4,13 @@ module.exports = (config) => ({ type: "watch", icon: "../../assets/icon.png", deploymentTarget: "10.0", - frameworks: ["SwiftUI"], + frameworks: ["SwiftUI", "HealthKit"], entitlements: { "com.apple.security.application-groups": config.ios?.entitlements?.[ "com.apple.security.application-groups" ] ?? [], + "com.apple.developer.healthkit": true, + "com.apple.developer.healthkit.access": [], }, });