fix(watchos): WorkoutManager @MainActor — concurrency Swift 6 clean
Classe @MainActor (implicitement Sendable) ; callbacks HealthKit nonisolated qui
calculent les valeurs hors-main puis hoppent via Task { @MainActor }. Récupère le
builder depuis la session passée au délégué (plus de capture de self non-Sendable).
Build watchsimulator : 0 warning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,9 @@ import Foundation
|
|||||||
import HealthKit
|
import HealthKit
|
||||||
|
|
||||||
/// Pilote la séance via HKLiveWorkoutBuilder et pousse les metrics vers l'iPhone.
|
/// 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 {
|
final class WorkoutManager: NSObject, ObservableObject {
|
||||||
static let shared = WorkoutManager()
|
static let shared = WorkoutManager()
|
||||||
|
|
||||||
@@ -15,12 +18,7 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
private let healthStore = HKHealthStore()
|
private let healthStore = HKHealthStore()
|
||||||
private var session: HKWorkoutSession?
|
private var session: HKWorkoutSession?
|
||||||
private var builder: HKLiveWorkoutBuilder?
|
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 var lastSentAt = Date.distantPast
|
||||||
private let sendInterval: TimeInterval = 2
|
private let sendInterval: TimeInterval = 2
|
||||||
|
|
||||||
@@ -29,11 +27,10 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
func startWorkout(activityType: HKWorkoutActivityType,
|
func startWorkout(activityType: HKWorkoutActivityType,
|
||||||
locationType: HKWorkoutSessionLocationType) async {
|
locationType: HKWorkoutSessionLocationType) async {
|
||||||
guard HKHealthStore.isHealthDataAvailable() else {
|
guard HKHealthStore.isHealthDataAvailable() else {
|
||||||
setStatus("HealthKit indisponible sur cet appareil")
|
statusMessage = "HealthKit indisponible sur cet appareil"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
distanceType = Self.distanceType(for: activityType)
|
let distanceType = Self.distanceType(for: activityType)
|
||||||
|
|
||||||
let hr = HKQuantityType(.heartRate)
|
let hr = HKQuantityType(.heartRate)
|
||||||
let energy = HKQuantityType(.activeEnergyBurned)
|
let energy = HKQuantityType(.activeEnergyBurned)
|
||||||
let read: Set<HKObjectType> = [hr, energy, distanceType]
|
let read: Set<HKObjectType> = [hr, energy, distanceType]
|
||||||
@@ -42,7 +39,7 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
do {
|
do {
|
||||||
try await healthStore.requestAuthorization(toShare: share, read: read)
|
try await healthStore.requestAuthorization(toShare: share, read: read)
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("Autorisation Santé refusée : \(error.localizedDescription)")
|
statusMessage = "Autorisation Santé refusée : \(error.localizedDescription)"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,12 +60,10 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
let start = Date()
|
let start = Date()
|
||||||
session.startActivity(with: start)
|
session.startActivity(with: start)
|
||||||
try await builder.beginCollection(at: start)
|
try await builder.beginCollection(at: start)
|
||||||
await MainActor.run {
|
isRunning = true
|
||||||
self.isRunning = true
|
statusMessage = nil
|
||||||
self.statusMessage = nil
|
|
||||||
}
|
|
||||||
} catch {
|
} 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 resume() { session?.resume() }
|
||||||
func end() { session?.end() }
|
func end() { session?.end() }
|
||||||
|
|
||||||
private func setStatus(_ message: String) {
|
/// Appelé sur le main depuis le callback de collecte (hors-main).
|
||||||
DispatchQueue.main.async { [weak self] in self?.statusMessage = message }
|
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 {
|
private static func distanceType(for activity: HKWorkoutActivityType) -> HKQuantityType {
|
||||||
@@ -88,70 +98,58 @@ final class WorkoutManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
|
extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
|
||||||
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder,
|
nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder,
|
||||||
didCollectDataOf collectedTypes: Set<HKSampleType>) {
|
didCollectDataOf collectedTypes: Set<HKSampleType>) {
|
||||||
let hrType = HKQuantityType(.heartRate)
|
let hrType = HKQuantityType(.heartRate)
|
||||||
let energyType = HKQuantityType(.activeEnergyBurned)
|
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 {
|
for type in collectedTypes {
|
||||||
guard let qType = type as? HKQuantityType,
|
guard let qType = type as? HKQuantityType,
|
||||||
let stats = workoutBuilder.statistics(for: qType) else { continue }
|
let stats = workoutBuilder.statistics(for: qType) else { continue }
|
||||||
if qType == hrType {
|
if qType == hrType {
|
||||||
let bpm = HKUnit.count().unitDivided(by: .minute())
|
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 {
|
} else if qType == energyType {
|
||||||
if let v = stats.sumQuantity()?.doubleValue(for: .kilocalorie()) { bEnergy = v }
|
kcal = stats.sumQuantity()?.doubleValue(for: .kilocalorie())
|
||||||
} else if qType == distanceType {
|
} else if qType == walkRun || qType == cycling {
|
||||||
if let v = stats.sumQuantity()?.doubleValue(for: .meter()) { bDistance = v }
|
dist = stats.sumQuantity()?.doubleValue(for: .meter())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let elapsed = workoutBuilder.elapsedTime
|
let elapsed = workoutBuilder.elapsedTime
|
||||||
publish(elapsed: elapsed)
|
Task { @MainActor in
|
||||||
maybeSend(elapsed: elapsed)
|
self.ingest(hr: hr, kcal: kcal, dist: dist, 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) {
|
nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
|
||||||
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 {
|
extension WorkoutManager: HKWorkoutSessionDelegate {
|
||||||
func workoutSession(_ workoutSession: HKWorkoutSession,
|
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
|
||||||
didChangeTo toState: HKWorkoutSessionState,
|
didChangeTo toState: HKWorkoutSessionState,
|
||||||
from fromState: HKWorkoutSessionState,
|
from fromState: HKWorkoutSessionState,
|
||||||
date: Date) {
|
date: Date) {
|
||||||
guard toState == .ended, let builder else { return }
|
guard toState == .ended else { return }
|
||||||
Task { [weak self] in
|
let builder = workoutSession.associatedWorkoutBuilder()
|
||||||
|
Task {
|
||||||
try? await builder.endCollection(at: date)
|
try? await builder.endCollection(at: date)
|
||||||
_ = try? await builder.finishWorkout()
|
_ = try? await builder.finishWorkout()
|
||||||
await MainActor.run { self?.isRunning = false }
|
|
||||||
}
|
}
|
||||||
|
Task { @MainActor in self.isRunning = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
|
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
|
||||||
setStatus("Erreur séance : \(error.localizedDescription)")
|
didFailWithError error: Error) {
|
||||||
DispatchQueue.main.async { [weak self] in self?.isRunning = false }
|
let message = error.localizedDescription
|
||||||
|
Task { @MainActor in
|
||||||
|
self.statusMessage = "Erreur séance : \(message)"
|
||||||
|
self.isRunning = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user