Replaces the small target-HR pill on the iPhone run screen with a glanceable big-digit strip between the timer flow and the figure — heart rate (band-tinted with the cue arrow when the run carries a target), HR zone, active calories, and the workout stopwatch. A horizontal band in portrait, a vertical column in landscape (the inverse of the half-and-half split, so it always sits between them). The watch now rides the running calorie total and the HR zone (1-5, computed watch-side where max HR is known) along with each live HR sample; absent keys keep older builds wire-compatible both ways. LiveRunState holds all three under the shared staleness expiry. The screenshot rig seeds believable values so the run capture shows the panel populated.
295 lines
12 KiB
Swift
295 lines
12 KiB
Swift
//
|
||
// 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
|
||
|
||
/// When the running session started, for the coordinator's session-age rule (a
|
||
/// too-young `.finish` demotes to `.discard`). `distantPast` for a recovered
|
||
/// session — it predates this process, so it's never "young".
|
||
private(set) var sessionStartDate: Date?
|
||
|
||
/// 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?
|
||
|
||
/// One live reading for the phone's status panel: the fresh heart rate plus the
|
||
/// session's running calorie total and the HR zone (1–5) that rate falls in.
|
||
/// `kcal`/`zone` are nil while unknown (no energy sample yet / max HR unknown).
|
||
struct LiveSample {
|
||
let bpm: Double
|
||
let kcal: Double?
|
||
let zone: Int?
|
||
}
|
||
|
||
/// Fired with each fresh heart-rate sample while the session runs. The bridge hangs
|
||
/// off this to forward the reading to the phone (best-effort, display-only) — a
|
||
/// callback rather than observation so the sensor stream has exactly one push path.
|
||
var onHeartRateSample: ((LiveSample) -> Void)?
|
||
|
||
/// 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) }
|
||
sessionStartDate = startDate
|
||
isRunning = true
|
||
} catch {
|
||
clear()
|
||
}
|
||
}
|
||
|
||
/// Re-adopt a session that outlived a crash or reboot. watchOS keeps the
|
||
/// `HKWorkoutSession` alive across an app death and relaunches us expecting a
|
||
/// recovery call; without it the session is orphaned — its `HKWorkout` never saved
|
||
/// and the live HUD dead. Called once at launch; a no-op when there's nothing to
|
||
/// recover or a fresh start already claimed the slot.
|
||
func recover() {
|
||
guard HKHealthStore.isHealthDataAvailable(), session == nil else { return }
|
||
Task {
|
||
guard let recovered = try? await healthStore.recoverActiveWorkoutSession(),
|
||
self.session == nil else { return }
|
||
adopt(recovered)
|
||
}
|
||
}
|
||
|
||
private func adopt(_ recovered: HKWorkoutSession) {
|
||
let builder = recovered.associatedWorkoutBuilder()
|
||
builder.dataSource = HKLiveWorkoutDataSource(
|
||
healthStore: healthStore, workoutConfiguration: recovered.workoutConfiguration)
|
||
recovered.delegate = self
|
||
builder.delegate = self
|
||
self.session = recovered
|
||
self.builder = builder
|
||
|
||
// Zone seconds accumulated before the crash are gone (they lived in memory);
|
||
// resume accumulation from here rather than faking the missing stretch.
|
||
resetMetricAccumulators()
|
||
maxHeartRate = computeMaxHeartRate()
|
||
|
||
sessionStartDate = .distantPast
|
||
isRunning = true
|
||
}
|
||
|
||
/// 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[Self.zoneIndex(for: prevHR, maxHR: maxHeartRate)] += dt }
|
||
}
|
||
|
||
// Refresh the calorie total before forwarding, so the sample carries it.
|
||
currentActiveEnergyKcal = builder?.statistics(for: HKQuantityType(.activeEnergyBurned))?
|
||
.sumQuantity()?.doubleValue(for: .kilocalorie())
|
||
|
||
if let newHR {
|
||
currentHeartRate = newHR
|
||
let zone = maxHeartRate.map { Self.zoneIndex(for: newHR, maxHR: $0) + 1 }
|
||
onHeartRateSample?(LiveSample(bpm: newHR, kcal: currentActiveEnergyKcal, zone: zone))
|
||
}
|
||
lastHRSampleDate = now
|
||
}
|
||
|
||
/// Bucket an instantaneous heart rate into one of five zones (0–4) by its fraction of the
|
||
/// user's max HR: <60% → 0, 60–70% → 1, 70–80% → 2, 80–90% → 3, ≥90% → 4. Pure (no session
|
||
/// or sensor state), so it's `nonisolated static` and unit-testable off the main actor.
|
||
nonisolated static 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
|
||
}
|
||
|
||
/// The system ended the session out from under us (resource reclaim, watchOS policy).
|
||
/// Salvage what the builder collected into a real Health save instead of dropping it —
|
||
/// the run documents stay active, so no metrics are forwarded to the phone (there is no
|
||
/// completed run to attach them to); the `HKWorkout` itself is what must not be lost.
|
||
private func salvageSystemEndedSession(at endDate: Date) {
|
||
guard let builder, !isFinishing else { clear(); return }
|
||
isFinishing = true
|
||
Task {
|
||
defer { clear() }
|
||
try? await builder.endCollection(at: endDate)
|
||
_ = try? await builder.finishWorkout()
|
||
}
|
||
}
|
||
|
||
private func clear() {
|
||
session = nil
|
||
builder = nil
|
||
isRunning = false
|
||
isFinishing = false
|
||
sessionStartDate = nil
|
||
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 act if we didn't drive the end ourselves (finishAndSave/discard set
|
||
// `isFinishing` and clear on their own). A system-ended session is salvaged
|
||
// into a Health save rather than silently dropped.
|
||
Task { @MainActor in
|
||
if !self.isFinishing { self.salvageSystemEndedSession(at: date) }
|
||
}
|
||
}
|
||
|
||
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<HKSampleType>) {
|
||
Task { @MainActor in self.refreshLiveStats() }
|
||
}
|
||
|
||
nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
|
||
}
|