Files
coach-ios/ios/App/CoachWatch/WorkoutManager.swift
Sylvain c6f5a7ffc9 feat(watchos): WorkoutManager (HKLiveWorkoutBuilder) — séance live + push iPhone
HKWorkoutSession(healthStore:configuration:) + associatedWorkoutBuilder +
HKLiveWorkoutDataSource. Collecte FC (mostRecent), calories/distance (sum) via
statistics(for:), publication @Published sur main, envoi throttlé (2s) vers
ConnectivityManager. Clôture async endCollection(at:)/finishWorkout(). Build OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:20:11 +02:00

158 lines
6.1 KiB
Swift

import Foundation
import HealthKit
/// Pilote la séance via HKLiveWorkoutBuilder et pousse les metrics vers l'iPhone.
final class WorkoutManager: NSObject, ObservableObject {
static let shared = WorkoutManager()
@Published private(set) var heartRate = 0.0
@Published private(set) var activeEnergyKcal = 0.0
@Published private(set) var distanceMeters = 0.0
@Published private(set) var elapsedSec = 0.0
@Published private(set) var isRunning = false
@Published private(set) var statusMessage: String?
private let healthStore = HKHealthStore()
private var session: HKWorkoutSession?
private var builder: HKLiveWorkoutBuilder?
private var distanceType = HKQuantityType(.distanceWalkingRunning)
// Valeurs de travail, mutées uniquement dans les callbacks HealthKit (livrés sérialisés).
private var bHeartRate = 0.0
private var bEnergy = 0.0
private var bDistance = 0.0
private var lastSentAt = Date.distantPast
private let sendInterval: TimeInterval = 2
private override init() { super.init() }
func startWorkout(activityType: HKWorkoutActivityType,
locationType: HKWorkoutSessionLocationType) async {
guard HKHealthStore.isHealthDataAvailable() else {
setStatus("HealthKit indisponible sur cet appareil")
return
}
distanceType = Self.distanceType(for: activityType)
let hr = HKQuantityType(.heartRate)
let energy = HKQuantityType(.activeEnergyBurned)
let read: Set<HKObjectType> = [hr, energy, distanceType]
let share: Set<HKSampleType> = [HKQuantityType.workoutType(), hr, energy, distanceType]
do {
try await healthStore.requestAuthorization(toShare: share, read: read)
} catch {
setStatus("Autorisation Santé refusée : \(error.localizedDescription)")
return
}
let config = HKWorkoutConfiguration()
config.activityType = activityType
config.locationType = locationType
do {
let session = try HKWorkoutSession(healthStore: healthStore, configuration: config)
let builder = session.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore,
workoutConfiguration: config)
session.delegate = self
builder.delegate = self
self.session = session
self.builder = builder
let start = Date()
session.startActivity(with: start)
try await builder.beginCollection(at: start)
await MainActor.run {
self.isRunning = true
self.statusMessage = nil
}
} catch {
setStatus("Démarrage séance impossible : \(error.localizedDescription)")
}
}
func pause() { session?.pause() }
func resume() { session?.resume() }
func end() { session?.end() }
private func setStatus(_ message: String) {
DispatchQueue.main.async { [weak self] in self?.statusMessage = message }
}
private static func distanceType(for activity: HKWorkoutActivityType) -> HKQuantityType {
activity == .cycling
? HKQuantityType(.distanceCycling)
: HKQuantityType(.distanceWalkingRunning)
}
}
extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder,
didCollectDataOf collectedTypes: Set<HKSampleType>) {
let hrType = HKQuantityType(.heartRate)
let energyType = HKQuantityType(.activeEnergyBurned)
for type in collectedTypes {
guard let qType = type as? HKQuantityType,
let stats = workoutBuilder.statistics(for: qType) else { continue }
if qType == hrType {
let bpm = HKUnit.count().unitDivided(by: .minute())
if let v = stats.mostRecentQuantity()?.doubleValue(for: bpm) { bHeartRate = v }
} else if qType == energyType {
if let v = stats.sumQuantity()?.doubleValue(for: .kilocalorie()) { bEnergy = v }
} else if qType == distanceType {
if let v = stats.sumQuantity()?.doubleValue(for: .meter()) { bDistance = v }
}
}
let elapsed = workoutBuilder.elapsedTime
publish(elapsed: elapsed)
maybeSend(elapsed: elapsed)
}
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
private func publish(elapsed: TimeInterval) {
let hr = bHeartRate, kcal = bEnergy, dist = bDistance
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.heartRate = hr
self.activeEnergyKcal = kcal
self.distanceMeters = dist
self.elapsedSec = elapsed
}
}
private func maybeSend(elapsed: TimeInterval) {
let now = Date()
guard now.timeIntervalSince(lastSentAt) >= sendInterval else { return }
lastSentAt = now
ConnectivityManager.shared.sendSample([
"heartRate": bHeartRate,
"activeEnergyKcal": bEnergy,
"distanceMeters": bDistance,
"elapsedSec": elapsed,
"ts": now.timeIntervalSince1970
])
}
}
extension WorkoutManager: HKWorkoutSessionDelegate {
func workoutSession(_ workoutSession: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date) {
guard toState == .ended, let builder else { return }
Task { [weak self] in
try? await builder.endCollection(at: date)
_ = try? await builder.finishWorkout()
await MainActor.run { self?.isRunning = false }
}
}
func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
setStatus("Erreur séance : \(error.localizedDescription)")
DispatchQueue.main.async { [weak self] in self?.isRunning = false }
}
}