import Foundation import HealthKit import os private let workoutLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "workout") /// Pilote la séance via HKLiveWorkoutBuilder et pousse les metrics vers l'iPhone. /// `@MainActor` (donc Sendable) : l'état observable vit sur le main ; les callbacks /// HealthKit, livrés hors-main, sont `nonisolated` et hoppent vers le main. @MainActor 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 lastSentAt = Date.distantPast private let sendInterval: TimeInterval = 2 private override init() { super.init() } func startWorkout(activityType: HKWorkoutActivityType, locationType: HKWorkoutSessionLocationType) async { guard !isRunning, session == nil else { return } guard HKHealthStore.isHealthDataAvailable() else { statusMessage = "HealthKit indisponible sur cet appareil" return } let distanceType = Self.distanceType(for: activityType) let hr = HKQuantityType(.heartRate) let energy = HKQuantityType(.activeEnergyBurned) let read: Set = [hr, energy, distanceType] let share: Set = [HKQuantityType.workoutType(), hr, energy, distanceType] do { try await healthStore.requestAuthorization(toShare: share, read: read) let woStatus = healthStore.authorizationStatus(for: HKQuantityType.workoutType()) workoutLog.info("requestAuthorization OK; workoutType share status=\(woStatus.rawValue)") } catch { workoutLog.error("requestAuthorization threw: \(error.localizedDescription)") statusMessage = "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) workoutLog.info("startActivity called, beginning collection…") try await builder.beginCollection(at: start) isRunning = true statusMessage = nil workoutLog.info("beginCollection OK, isRunning=true") } catch { self.session = nil self.builder = nil let ns = error as NSError workoutLog.error("start failed domain=\(ns.domain) code=\(ns.code): \(error.localizedDescription)") statusMessage = "Démarrage séance impossible : \(error.localizedDescription)" } } func pause() { session?.pause() } func resume() { session?.resume() } func end() { session?.end() } /// Appelé sur le main depuis le callback de collecte (hors-main). private func ingest(hr: Double?, kcal: Double?, dist: Double?, elapsed: TimeInterval) { if let hr { heartRate = hr } if let kcal { activeEnergyKcal = kcal } if let dist { distanceMeters = dist } elapsedSec = elapsed let now = Date() guard now.timeIntervalSince(lastSentAt) >= sendInterval else { return } lastSentAt = now ConnectivityManager.shared.sendSample([ "heartRate": heartRate, "activeEnergyKcal": activeEnergyKcal, "distanceMeters": distanceMeters, "elapsedSec": elapsed, "ts": now.timeIntervalSince1970 ]) } private static func distanceType(for activity: HKWorkoutActivityType) -> HKQuantityType { activity == .cycling ? HKQuantityType(.distanceCycling) : HKQuantityType(.distanceWalkingRunning) } } extension WorkoutManager: HKLiveWorkoutBuilderDelegate { nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set) { let hrType = HKQuantityType(.heartRate) let energyType = HKQuantityType(.activeEnergyBurned) let walkRun = HKQuantityType(.distanceWalkingRunning) let cycling = HKQuantityType(.distanceCycling) var hr: Double? var kcal: Double? var dist: Double? 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()) hr = stats.mostRecentQuantity()?.doubleValue(for: bpm) } else if qType == energyType { kcal = stats.sumQuantity()?.doubleValue(for: .kilocalorie()) } else if qType == walkRun || qType == cycling { dist = stats.sumQuantity()?.doubleValue(for: .meter()) } } let elapsed = workoutBuilder.elapsedTime Task { @MainActor in self.ingest(hr: hr, kcal: kcal, dist: dist, elapsed: elapsed) } } nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {} } extension WorkoutManager: HKWorkoutSessionDelegate { nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) { workoutLog.info("session didChangeTo state=\(toState.rawValue) from=\(fromState.rawValue)") guard toState == .ended else { return } let builder = workoutSession.associatedWorkoutBuilder() Task { try? await builder.endCollection(at: date) _ = try? await builder.finishWorkout() } Task { @MainActor in self.isRunning = false self.session = nil self.builder = nil } } nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { let message = error.localizedDescription let ns = error as NSError workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)") Task { @MainActor in self.statusMessage = "Erreur séance : \(message)" self.isRunning = false self.session = nil self.builder = nil } } }