diff --git a/ios/App/CoachWatch/WorkoutManager.swift b/ios/App/CoachWatch/WorkoutManager.swift index a86556f..13c5a03 100644 --- a/ios/App/CoachWatch/WorkoutManager.swift +++ b/ios/App/CoachWatch/WorkoutManager.swift @@ -2,6 +2,9 @@ import Foundation import HealthKit /// 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() @@ -15,12 +18,7 @@ final class WorkoutManager: NSObject, ObservableObject { 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 @@ -29,11 +27,10 @@ final class WorkoutManager: NSObject, ObservableObject { func startWorkout(activityType: HKWorkoutActivityType, locationType: HKWorkoutSessionLocationType) async { guard HKHealthStore.isHealthDataAvailable() else { - setStatus("HealthKit indisponible sur cet appareil") + statusMessage = "HealthKit indisponible sur cet appareil" return } - distanceType = Self.distanceType(for: activityType) - + let distanceType = Self.distanceType(for: activityType) let hr = HKQuantityType(.heartRate) let energy = HKQuantityType(.activeEnergyBurned) let read: Set = [hr, energy, distanceType] @@ -42,7 +39,7 @@ final class WorkoutManager: NSObject, ObservableObject { do { try await healthStore.requestAuthorization(toShare: share, read: read) } catch { - setStatus("Autorisation Santé refusée : \(error.localizedDescription)") + statusMessage = "Autorisation Santé refusée : \(error.localizedDescription)" return } @@ -63,12 +60,10 @@ final class WorkoutManager: NSObject, ObservableObject { let start = Date() session.startActivity(with: start) try await builder.beginCollection(at: start) - await MainActor.run { - self.isRunning = true - self.statusMessage = nil - } + isRunning = true + statusMessage = nil } catch { - setStatus("Démarrage séance impossible : \(error.localizedDescription)") + statusMessage = "Démarrage séance impossible : \(error.localizedDescription)" } } @@ -76,8 +71,23 @@ final class WorkoutManager: NSObject, ObservableObject { func resume() { session?.resume() } func end() { session?.end() } - private func setStatus(_ message: String) { - DispatchQueue.main.async { [weak self] in self?.statusMessage = message } + /// 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 { @@ -88,70 +98,58 @@ final class WorkoutManager: NSObject, ObservableObject { } extension WorkoutManager: HKLiveWorkoutBuilderDelegate { - func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, - didCollectDataOf collectedTypes: Set) { + 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()) - if let v = stats.mostRecentQuantity()?.doubleValue(for: bpm) { bHeartRate = v } + hr = stats.mostRecentQuantity()?.doubleValue(for: bpm) } 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 } + kcal = stats.sumQuantity()?.doubleValue(for: .kilocalorie()) + } else if qType == walkRun || qType == cycling { + dist = stats.sumQuantity()?.doubleValue(for: .meter()) } } 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 + Task { @MainActor in + self.ingest(hr: hr, kcal: kcal, dist: dist, elapsed: 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 - ]) - } + nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {} } 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 + nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, + didChangeTo toState: HKWorkoutSessionState, + from fromState: HKWorkoutSessionState, + date: Date) { + guard toState == .ended else { return } + let builder = workoutSession.associatedWorkoutBuilder() + Task { try? await builder.endCollection(at: date) _ = try? await builder.finishWorkout() - await MainActor.run { self?.isRunning = false } } + Task { @MainActor in 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 } + nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, + didFailWithError error: Error) { + let message = error.localizedDescription + Task { @MainActor in + self.statusMessage = "Erreur séance : \(message)" + self.isRunning = false + } } }