Files
coach-ios/ios/App/CoachWatch/WorkoutManager.swift
Sylvain Bettinelli 21d39f2e98 Piloter la séance depuis la Live Activity (Pause / Terminer)
Comme l'app Exercice : deux boutons sur la bannière de l'écran verrouillé et
dans l'île dépliée. iOS n'expose aucun geste de balayage pour révéler des
actions — depuis iOS 17, ce sont des boutons intégrés (App Intents), et c'est
le seul mécanisme public.

- Intents `CoachTogglePauseIntent` / `CoachEndWorkoutIntent`, conformes à
  LiveActivityIntent (sans quoi `perform()` n'est jamais appelé).
- Ils vivent dans CoachLiveActivityAttributes.swift, seul fichier déjà membre
  des deux targets : un fichier neuf imposerait une manip Target Membership
  dans Xcode, source d'erreurs répétées ici.
- Commandes relayées à la montre par WCSession, avec repli transferUserInfo :
  isReachable retombe à false quand la séance tourne en arrière-plan profond,
  la commande est alors différée au réveil de l'app montre.
- L'état de pause remonte depuis HKWorkoutSession (seule source fiable : la
  montre peut mettre en pause d'elle-même) et bascule le libellé du bouton.
- Le watchdog de LiveStore est neutralisé pendant la pause : sans ça, l'absence
  de samples aurait affiché « connexion perdue » puis terminé l'activité.
- L'app watchOS gagne le bouton Pause qui lui manquait, aligné sur le même état.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:25:14 +00:00

267 lines
11 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 isPaused = false
@Published private(set) var statusMessage: String?
private let healthStore = HKHealthStore()
private var session: HKWorkoutSession?
private var builder: HKLiveWorkoutBuilder?
private var lastSentAt = Date.distantPast
// Throttle léger anti-saturation BLE pour les metrics à haute fréquence
// (calories/distance). La FC, elle, est poussée immédiatement à chaque
// nouvel échantillon comme l'app Exercice native d'Apple.
private let minSendInterval: TimeInterval = 0.5
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)"
}
}
/// Pause / reprise. Déclenchées depuis la montre OU depuis les boutons de la
/// Live Activity de l'iPhone (via `ConnectivityManager.handleCommand`).
/// Avec une vraie session, `isPaused` n'est pas positionné ici : il suit le
/// changement d'état HealthKit, seule source fiable (la montre peut aussi
/// mettre en pause d'elle-même).
func pause() {
guard simTask == nil else { setSimPaused(true); return }
session?.pause()
}
func resume() {
guard simTask == nil else { setSimPaused(false); return }
session?.resume()
}
/// Le mode test ne passe pas par HKWorkoutSession : il n'émet aucun
/// changement d'état, l'iPhone doit donc être prévenu ici.
private func setSimPaused(_ paused: Bool) {
isPaused = paused
ConnectivityManager.shared.sendPaused(paused)
}
func end() {
if let simTask {
simTask.cancel()
self.simTask = nil
isRunning = false
statusMessage = nil
// Le mode test ne passe pas par HKWorkoutSession : c'est ici le
// seul endroit où l'iPhone peut apprendre que le flux s'arrête.
ConnectivityManager.shared.sendEnded(reason: "sim")
} 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()
// Nouvelle FC -> envoi immédiat (temps réel, comme Apple). Sinon (mise à
// jour calories/distance seule), on applique le throttle anti-saturation.
let heartRateUpdated = hr != nil
guard heartRateUpdated || now.timeIntervalSince(lastSentAt) >= minSendInterval 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)")
// Pause / reprise : c'est la session HealthKit qui fait foi, pas
// l'appel à pause() la montre peut mettre en pause d'elle-même
// (auto-pause) sans passer par nous.
if toState == .paused || toState == .running {
let paused = (toState == .paused)
Task { @MainActor in
self.isPaused = paused
ConnectivityManager.shared.sendPaused(paused)
}
}
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.isPaused = false
self.session = nil
self.builder = nil
// Fin explicite vers l'iPhone : sans elle, sa Live Activity reste
// affichée avec la dernière FC reçue jusqu'à expiration d'ActivityKit.
ConnectivityManager.shared.sendEnded(reason: "session-ended")
}
}
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
ConnectivityManager.shared.sendEnded(reason: "session-failed")
}
}
}