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>
This commit is contained in:
Sylvain Bettinelli
2026-08-11 13:25:14 +00:00
parent ce048ee634
commit 21d39f2e98
8 changed files with 283 additions and 15 deletions

View File

@@ -16,6 +16,7 @@ final class WorkoutManager: NSObject, ObservableObject {
@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()
@@ -88,8 +89,27 @@ final class WorkoutManager: NSObject, ObservableObject {
}
}
func pause() { session?.pause() }
func resume() { session?.resume() }
/// 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 {
@@ -203,6 +223,16 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
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 {
@@ -211,6 +241,7 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
}
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