Bouton "Test (FC simulée)" qui envoie des samples synthétiques (FC ~110-150, calories, distance, durée) via WCSession toutes les 2s, sans démarrer de séance HealthKit. Permet de valider watch -> iPhone -> /live indépendamment de l'autorisation Santé (bloquée par un sous-système Santé coincé sur la Watch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
223 lines
8.9 KiB
Swift
223 lines
8.9 KiB
Swift
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<Void, Never>?
|
|
|
|
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<HKObjectType> = [hr, energy, distanceType]
|
|
let share: Set<HKSampleType> = [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<HKSampleType>) {
|
|
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
|
|
}
|
|
}
|
|
}
|