From c6f5a7ffc9b28c00aca3e336f4075d3b3c328b9a Mon Sep 17 00:00:00 2001 From: Sylvain Date: Sun, 24 May 2026 16:20:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(watchos):=20WorkoutManager=20(HKLiveWorkou?= =?UTF-8?q?tBuilder)=20=E2=80=94=20s=C3=A9ance=20live=20+=20push=20iPhone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ios/App/App.xcodeproj/project.pbxproj | 4 + ios/App/CoachWatch/WorkoutManager.swift | 157 ++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 ios/App/CoachWatch/WorkoutManager.swift diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index f4e2a3f..c3a268a 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A06CDFF5B4F7852B6329862 /* ContentView.swift */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */; }; 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; @@ -67,6 +68,7 @@ 88A837392FAA741C00DDD144 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; }; 88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = ""; }; 8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = ""; }; 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; A420239FC6D065C8A599B61C /* ConnectivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityManager.swift; sourceTree = ""; }; C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = ""; }; @@ -154,6 +156,7 @@ 82122ACF7E5A608AD1D55D24 /* Info.plist */, D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */, A420239FC6D065C8A599B61C /* ConnectivityManager.swift */, + 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */, ); name = CoachWatch; path = CoachWatch; @@ -291,6 +294,7 @@ 543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */, 290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */, B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */, + 436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/App/CoachWatch/WorkoutManager.swift b/ios/App/CoachWatch/WorkoutManager.swift new file mode 100644 index 0000000..a86556f --- /dev/null +++ b/ios/App/CoachWatch/WorkoutManager.swift @@ -0,0 +1,157 @@ +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 = [hr, energy, distanceType] + let share: Set = [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) { + 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 } + } +}