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

@@ -102,6 +102,39 @@ final class ConnectivityManager: NSObject, ObservableObject {
return true
}
/// Signale à l'iPhone que la séance est en pause / a repris, pour que le
/// bouton de la Live Activity affiche le bon état.
func sendPaused(_ paused: Bool) {
queue.async {
let session = WCSession.default
guard session.activationState == .activated else { return }
let payload: [String: Any] = ["paused": paused, "ts": Date().timeIntervalSince1970]
if session.isReachable {
session.sendMessage(payload, replyHandler: nil) { _ in
session.transferUserInfo(payload)
}
} else {
session.transferUserInfo(payload)
}
}
}
/// Commande de pilotage envoyée par la Live Activity de l'iPhone.
/// Arrive soit par `sendMessage` (immédiat), soit par `transferUserInfo`
/// (différé) quand la montre n'était pas joignable.
private func handleCommand(_ dict: [String: Any]) {
guard let cmd = dict["workoutCommand"] as? String else { return }
connLog.info("commande reçue de l'iPhone : \(cmd, privacy: .public)")
Task { @MainActor in
switch cmd {
case "pause": WorkoutManager.shared.pause()
case "resume": WorkoutManager.shared.resume()
case "end": WorkoutManager.shared.end()
default: connLog.error("commande inconnue : \(cmd, privacy: .public)")
}
}
}
/// À appeler sur `queue` uniquement.
private func flushLocked() {
guard !buffer.isEmpty else { return }
@@ -146,6 +179,12 @@ extension ConnectivityManager: WCSessionDelegate {
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
storeWidgetSnapshot(userInfo)
storeRoutineSnapshot(userInfo)
handleCommand(userInfo)
}
/// Temps réel : commandes de la Live Activity quand la montre est joignable.
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
handleCommand(message)
}
/// Routine quotidienne poussée par l'iPhone (`CoachRoutineBridge`).