// // WorkoutSessionManager.swift // Workouts Watch App // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import Foundation import HealthKit import Observation /// Runs an `HKWorkoutSession` (with a live builder) for the duration of a watch /// workout. /// /// When the phone launches us via `startWatchApp(toHandle:)`, watchOS hands the app /// delegate a workout configuration; starting a session with it grants the /// freshly-launched app foreground runtime (and keeps it alive while the wrist is /// down). The attached `HKLiveWorkoutBuilder` makes watchOS collect heart rate and /// active energy from the sensors, so finishing the session saves a real `HKWorkout` /// that feeds the Activity rings — and gives us the live HUD values and the metrics /// we send back to the phone. @Observable @MainActor final class WorkoutSessionManager: NSObject { private let healthStore = HKHealthStore() private var session: HKWorkoutSession? private var builder: HKLiveWorkoutBuilder? private(set) var isRunning = false /// Live values for the in-workout HUD. Nil until the first sample arrives (or if /// the corresponding read authorization was denied). private(set) var currentHeartRate: Double? private(set) var currentActiveEnergyKcal: Double? /// Seconds accumulated in each of 5 heart-rate zones (low→high). Built on the /// watch because it's the only place with the full HR stream. Empty/ignored when /// the user's age (hence max HR) is unknown. private var hrZoneSeconds = [Double](repeating: 0, count: 5) private var lastHRSampleDate: Date? private var maxHeartRate: Double? /// Guards the terminal paths so a late delegate callback can't double-finish. private var isFinishing = false /// Prompt for the authorization the live session needs: share workouts + active /// energy (so the session can write them), read heart rate + active energy (to /// surface them), and read date of birth (to derive HR zones). Called once at /// launch so a later phone-initiated launch starts cleanly. func requestAuthorization() { guard HKHealthStore.isHealthDataAvailable() else { return } healthStore.requestAuthorization( toShare: WorkoutHealthAuthorization.watchShare, read: WorkoutHealthAuthorization.watchRead ) { _, _ in } } /// Start the session for a phone-launched workout. Idempotent — a second launch /// while one is already running is ignored. func start(with configuration: HKWorkoutConfiguration) { guard HKHealthStore.isHealthDataAvailable(), session == nil else { return } do { let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration) let builder = session.associatedWorkoutBuilder() builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration) session.delegate = self builder.delegate = self self.session = session self.builder = builder resetMetricAccumulators() maxHeartRate = computeMaxHeartRate() let startDate = Date() session.startActivity(with: startDate) Task { try? await builder.beginCollection(at: startDate) } isRunning = true } catch { clear() } } /// Finish the session and save it to Health, returning the captured metrics so the /// caller can attach them to the workout and forward to the phone. `totalVolume` /// is left nil here — the caller fills it from the workout's logs. func finishAndSave() async -> WorkoutMetrics? { guard let session, let builder, !isFinishing else { clear(); return nil } isFinishing = true let endDate = Date() session.end() do { try await builder.endCollection(at: endDate) let saved = try await builder.finishWorkout() let metrics = makeMetrics(savedWorkout: saved, endDate: endDate) clear() return metrics } catch { clear() return nil } } /// End the session and throw the data away — nothing is saved to Health. Used when /// the workout was discarded rather than completed. func discard() { guard let session, let builder, !isFinishing else { clear(); return } isFinishing = true session.end() builder.discardWorkout() clear() } // MARK: - Metric helpers private func makeMetrics(savedWorkout: HKWorkout?, endDate: Date) -> WorkoutMetrics { let hrUnit = HKUnit.count().unitDivided(by: .minute()) let energy = builder?.statistics(for: HKQuantityType(.activeEnergyBurned))? .sumQuantity()?.doubleValue(for: .kilocalorie()) let hr = builder?.statistics(for: HKQuantityType(.heartRate)) let zones = (maxHeartRate != nil && hrZoneSeconds.contains { $0 > 0 }) ? hrZoneSeconds : nil return WorkoutMetrics( activeEnergyKcal: energy, avgHeartRate: hr?.averageQuantity()?.doubleValue(for: hrUnit), maxHeartRate: hr?.maximumQuantity()?.doubleValue(for: hrUnit), minHeartRate: hr?.minimumQuantity()?.doubleValue(for: hrUnit), totalVolume: nil, hrZoneSeconds: zones, healthKitWorkoutUUID: savedWorkout?.uuid.uuidString, source: .watch, recordedAt: endDate ) } /// Pull the latest live stats off the builder for the HUD, and fold the elapsed /// time into the right HR zone. private func refreshLiveStats() { let hrUnit = HKUnit.count().unitDivided(by: .minute()) let newHR = builder?.statistics(for: HKQuantityType(.heartRate))? .mostRecentQuantity()?.doubleValue(for: hrUnit) let now = Date() if let maxHeartRate, let prevHR = currentHeartRate, let last = lastHRSampleDate { let dt = now.timeIntervalSince(last) if dt > 0, dt < 60 { hrZoneSeconds[zoneIndex(for: prevHR, maxHR: maxHeartRate)] += dt } } if let newHR { currentHeartRate = newHR } lastHRSampleDate = now currentActiveEnergyKcal = builder?.statistics(for: HKQuantityType(.activeEnergyBurned))? .sumQuantity()?.doubleValue(for: .kilocalorie()) } private func zoneIndex(for hr: Double, maxHR: Double) -> Int { let ratio = hr / maxHR let thresholds = [0.6, 0.7, 0.8, 0.9] return thresholds.reduce(0) { $0 + (ratio >= $1 ? 1 : 0) } } /// Max HR ≈ 220 − age, from the user's date of birth. Nil when unavailable (read /// denied or DOB not set), which suppresses zone tracking rather than faking it. private func computeMaxHeartRate() -> Double? { guard let comps = try? healthStore.dateOfBirthComponents(), let birthYear = comps.year else { return nil } let thisYear = Calendar.current.component(.year, from: Date()) let age = thisYear - birthYear guard age > 0, age < 120 else { return nil } return Double(220 - age) } private func resetMetricAccumulators() { currentHeartRate = nil currentActiveEnergyKcal = nil hrZoneSeconds = [Double](repeating: 0, count: 5) lastHRSampleDate = nil maxHeartRate = nil } private func clear() { session = nil builder = nil isRunning = false isFinishing = false resetMetricAccumulators() } } // MARK: - HKWorkoutSessionDelegate extension WorkoutSessionManager: HKWorkoutSessionDelegate { nonisolated func workoutSession( _ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date ) { guard toState == .ended || toState == .stopped else { return } // Only reset if we didn't drive the end ourselves (finishAndSave/discard set // `isFinishing` and clear on their own); this catches system-ended sessions. Task { @MainActor in if !self.isFinishing { self.clear() } } } nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { Task { @MainActor in self.clear() } } } // MARK: - HKLiveWorkoutBuilderDelegate extension WorkoutSessionManager: HKLiveWorkoutBuilderDelegate { nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set) { Task { @MainActor in self.refreshLiveStats() } } nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {} }