feat(watchos): WorkoutManager (HKLiveWorkoutBuilder) — séance live + push iPhone
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) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A06CDFF5B4F7852B6329862 /* ContentView.swift */; };
|
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A06CDFF5B4F7852B6329862 /* ContentView.swift */; };
|
||||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
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, ); }; };
|
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 */; };
|
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
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 = "<group>"; };
|
88A837392FAA741C00DDD144 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||||
88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; };
|
88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutObserver.swift; sourceTree = "<group>"; };
|
||||||
8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; };
|
||||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
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 = "<group>"; };
|
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityManager.swift; sourceTree = "<group>"; };
|
||||||
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
|
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
|
||||||
@@ -154,6 +156,7 @@
|
|||||||
82122ACF7E5A608AD1D55D24 /* Info.plist */,
|
82122ACF7E5A608AD1D55D24 /* Info.plist */,
|
||||||
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */,
|
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */,
|
||||||
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */,
|
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */,
|
||||||
|
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */,
|
||||||
);
|
);
|
||||||
name = CoachWatch;
|
name = CoachWatch;
|
||||||
path = CoachWatch;
|
path = CoachWatch;
|
||||||
@@ -291,6 +294,7 @@
|
|||||||
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */,
|
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */,
|
||||||
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */,
|
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */,
|
||||||
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
|
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
|
||||||
|
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
157
ios/App/CoachWatch/WorkoutManager.swift
Normal file
157
ios/App/CoachWatch/WorkoutManager.swift
Normal file
@@ -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<HKObjectType> = [hr, energy, distanceType]
|
||||||
|
let share: Set<HKSampleType> = [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<HKSampleType>) {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user