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 speedKmh = 0.0 @Published private(set) var elapsedSec = 0.0 @Published private(set) var isRunning = false @Published private(set) var isPaused = false @Published private(set) var statusMessage: String? private let healthStore = HKHealthStore() private var session: HKWorkoutSession? private var builder: HKLiveWorkoutBuilder? private var lastSentAt = Date.distantPast // Throttle léger anti-saturation BLE pour les metrics à haute fréquence // (calories/distance). La FC, elle, est poussée immédiatement à chaque // nouvel échantillon — comme l'app Exercice native d'Apple. private let minSendInterval: TimeInterval = 0.5 private var simTask: Task? 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) // Vitesse instantanée mesurée par la montre. HKLiveWorkoutDataSource la // collecte d'office (course extérieure sur Series 6+, vélo sur // watchOS 11+), mais la lecture doit être autorisée. Sur un appareil qui // ne la produit pas, les stats restent vides : `ingest` retombe alors // sur la vitesse moyenne dérivée de la distance. let speedType = Self.speedType(for: activityType) let read: Set = [hr, energy, distanceType, speedType] 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)") if woStatus == .sharingDenied { statusMessage = "Accès Santé refusé. Active Coach dans Réglages → Santé → Accès aux données, puis relance." return } } 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)" } } /// Pause / reprise. Déclenchées depuis la montre OU depuis les boutons de la /// Live Activity de l'iPhone (via `ConnectivityManager.handleCommand`). /// Avec une vraie session, `isPaused` n'est pas positionné ici : il suit le /// changement d'état HealthKit, seule source fiable (la montre peut aussi /// mettre en pause d'elle-même). func pause() { guard simTask == nil else { setSimPaused(true); return } session?.pause() } func resume() { guard simTask == nil else { setSimPaused(false); return } session?.resume() } /// Le mode test ne passe pas par HKWorkoutSession : il n'émet aucun /// changement d'état, l'iPhone doit donc être prévenu ici. private func setSimPaused(_ paused: Bool) { isPaused = paused ConnectivityManager.shared.sendPaused(paused) } func end() { if let simTask { simTask.cancel() self.simTask = nil isRunning = false statusMessage = nil // Le mode test ne passe pas par HKWorkoutSession : c'est ici le // seul endroit où l'iPhone peut apprendre que le flux s'arrête. ConnectivityManager.shared.sendEnded(reason: "sim") } else { session?.end() } } /// Mode test : FC simulée, sans HealthKit, pour valider le flux watch -> iPhone -> /live. func startSimulation() { guard !isRunning else { return } isRunning = true statusMessage = "Mode test — FC simulée" let startedAt = Date() simTask = Task { [weak self] in while !Task.isCancelled { guard let self else { return } let elapsed = Date().timeIntervalSince(startedAt) let hr = 110 + 40 * abs(sin(elapsed / 30)) let kcal = elapsed / 6 let dist = elapsed * 2.6 self.heartRate = hr self.activeEnergyKcal = kcal self.distanceMeters = dist self.elapsedSec = elapsed self.speedKmh = 2.6 * 3.6 // la simulation avance à 2,6 m/s ConnectivityManager.shared.sendSample([ "heartRate": hr, "activeEnergyKcal": kcal, "distanceMeters": dist, "speedKmh": self.speedKmh, "elapsedSec": elapsed, "ts": Date().timeIntervalSince1970, "sim": true ]) try? await Task.sleep(nanoseconds: 2_000_000_000) } } } /// Appelé sur le main depuis le callback de collecte (hors-main). private func ingest(hr: Double?, kcal: Double?, dist: Double?, speedMs: Double?, elapsed: TimeInterval) { if let hr { heartRate = hr } if let kcal { activeEnergyKcal = kcal } if let dist { distanceMeters = dist } elapsedSec = elapsed // Vitesse instantanée de la montre si elle la mesure, sinon moyenne // depuis le départ (distance / temps). Les deux en km/h. if let speedMs { speedKmh = speedMs * 3.6 } else if elapsed > 5, distanceMeters > 0 { speedKmh = (distanceMeters / elapsed) * 3.6 } let now = Date() // Nouvelle FC -> envoi immédiat (temps réel, comme Apple). Sinon (mise à // jour calories/distance seule), on applique le throttle anti-saturation. let heartRateUpdated = hr != nil guard heartRateUpdated || now.timeIntervalSince(lastSentAt) >= minSendInterval else { return } lastSentAt = now ConnectivityManager.shared.sendSample([ "heartRate": heartRate, "activeEnergyKcal": activeEnergyKcal, "distanceMeters": distanceMeters, "speedKmh": speedKmh, "elapsedSec": elapsed, "ts": now.timeIntervalSince1970 ]) } private static func distanceType(for activity: HKWorkoutActivityType) -> HKQuantityType { activity == .cycling ? HKQuantityType(.distanceCycling) : HKQuantityType(.distanceWalkingRunning) } /// La marche n'a pas de type de vitesse dédié : on lui donne celui de la /// course, qui restera vide — la vitesse moyenne dérivée prend le relais. private static func speedType(for activity: HKWorkoutActivityType) -> HKQuantityType { activity == .cycling ? HKQuantityType(.cyclingSpeed) : HKQuantityType(.runningSpeed) } } 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) let runSpeed = HKQuantityType(.runningSpeed) let cycleSpeed = HKQuantityType(.cyclingSpeed) var hr: Double? var kcal: Double? var dist: Double? var speedMs: 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()) } else if qType == runSpeed || qType == cycleSpeed { // mostRecent : c'est bien la vitesse à l'instant t qu'on veut // afficher, pas la moyenne de la séance. let ms = HKUnit.meter().unitDivided(by: .second()) speedMs = stats.mostRecentQuantity()?.doubleValue(for: ms) } } let elapsed = workoutBuilder.elapsedTime Task { @MainActor in self.ingest(hr: hr, kcal: kcal, dist: dist, speedMs: speedMs, 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)") // Pause / reprise : c'est la session HealthKit qui fait foi, pas // l'appel à pause() — la montre peut mettre en pause d'elle-même // (auto-pause) sans passer par nous. if toState == .paused || toState == .running { let paused = (toState == .paused) Task { @MainActor in self.isPaused = paused ConnectivityManager.shared.sendPaused(paused) } } 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.isPaused = false self.session = nil self.builder = nil // Fin explicite vers l'iPhone : sans elle, sa Live Activity reste // affichée avec la dernière FC reçue jusqu'à expiration d'ActivityKit. ConnectivityManager.shared.sendEnded(reason: "session-ended") } } 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 ConnectivityManager.shared.sendEnded(reason: "session-failed") } } }