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 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) 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)") 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)" } } func pause() { session?.pause() } func resume() { session?.resume() } func end() { if let simTask { simTask.cancel() self.simTask = nil isRunning = false statusMessage = nil } 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 ConnectivityManager.shared.sendSample([ "heartRate": hr, "activeEnergyKcal": kcal, "distanceMeters": dist, "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?, 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 } } }