Files
coach-ios/ios/App/CoachWatch/ConnectivityManager.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

210 lines
8.4 KiB
Swift

import Foundation
import WatchConnectivity
import WidgetKit
import os
private let connLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "connectivity")
/// Envoie les samples de séance vers l'iPhone.
/// - `sendMessage` quand l'iPhone est joignable (temps réel)
/// - sinon buffer + `transferUserInfo` (livraison différée garantie)
final class ConnectivityManager: NSObject, ObservableObject {
static let shared = ConnectivityManager()
@Published private(set) var isReachable = false
private let queue = DispatchQueue(label: "ch.hypnotruck.coach.connectivity")
private var buffer: [[String: Any]] = []
private override init() {
super.init()
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
session.activate()
}
func sendSample(_ payload: [String: Any]) {
queue.async {
let session = WCSession.default
let hr = (payload["heartRate"] as? Double) ?? -1
connLog.info("sendSample activated=\(session.activationState == .activated, privacy: .public) reachable=\(session.isReachable, privacy: .public) hr=\(hr, privacy: .public)")
guard session.activationState == .activated else {
self.buffer.append(payload)
return
}
if session.isReachable {
session.sendMessage(payload, replyHandler: nil) { [weak self] _ in
// échec d'envoi temps réel -> bascule en livraison différée
self?.queue.async {
self?.buffer.append(payload)
self?.flushLocked()
}
}
} else {
self.buffer.append(payload)
self.flushLocked()
}
}
}
/// Signale la FIN de séance à l'iPhone.
///
/// Sans ce message, l'iPhone ne peut pas distinguer « séance terminée » de
/// « montre hors de portée » : le flux de samples s'arrête dans les deux
/// cas. Sa Live Activity restait alors figée sur la dernière FC reçue.
/// Les samples encore en attente partent AVANT la fin (flushLocked) pour
/// que l'ordre d'arrivée reste cohérent.
func sendEnded(reason: String = "user") {
queue.async {
let session = WCSession.default
guard session.activationState == .activated else { return }
self.flushLocked()
let payload: [String: Any] = [
"liveEnded": true,
"reason": reason,
"ts": Date().timeIntervalSince1970,
]
if session.isReachable {
session.sendMessage(payload, replyHandler: nil) { _ in
// Échec temps réel : livraison différée garantie.
session.transferUserInfo(payload)
}
} else {
session.transferUserInfo(payload)
}
connLog.info("fin de séance signalée à l'iPhone (reason=\(reason, privacy: .public))")
}
}
/// Envoie l'état COMPLET de la routine du jour vers l'iPhone, qui le POSTe
/// au serveur. Retourne `false` si rien n'a pu partir (l'appelant affiche
/// alors un bouton « Renvoyer »).
///
/// `sendMessage` quand l'iPhone est joignable, `transferUserInfo` sinon :
/// la coche faite pendant une séance, iPhone resté à la maison, partira au
/// prochain rapprochement.
@discardableResult
func sendRoutine(date: String, done: [String]) -> Bool {
let session = WCSession.default
guard session.activationState == .activated else { return false }
let payload: [String: Any] = ["routineDone": done, "date": date]
if session.isReachable {
session.sendMessage(payload, replyHandler: nil) { _ in
// Échec temps réel : bascule en livraison différée garantie.
session.transferUserInfo(payload)
}
} else {
session.transferUserInfo(payload)
}
connLog.info("routine envoyée à l'iPhone (\(done.count, privacy: .public) blocs)")
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 }
let session = WCSession.default
guard session.activationState == .activated else { return }
session.transferUserInfo(["samples": buffer])
buffer.removeAll()
}
private func updateReachable(_ value: Bool) {
DispatchQueue.main.async { [weak self] in self?.isReachable = value }
}
}
extension ConnectivityManager: WCSessionDelegate {
func session(_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?) {
updateReachable(session.isReachable)
if activationState == .activated {
queue.async { [weak self] in self?.flushLocked() }
}
}
func sessionReachabilityDidChange(_ session: WCSession) {
let reachable = session.isReachable
updateReachable(reachable)
if reachable {
queue.async { [weak self] in self?.flushLocked() }
}
}
// Réception du snapshot widget (iPhone Watch)
// L'iPhone (CoachWidgetBridge) pousse les données du jour ; on les persiste
// dans l'App Group de CETTE montre et on recharge les complications.
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
storeWidgetSnapshot(applicationContext)
storeRoutineSnapshot(applicationContext)
}
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`).
/// Les blocs voyagent avec l'état : la montre n'a aucune liste codée en dur,
/// modifier la routine côté web ne demande donc pas de rebuild watchOS.
private func storeRoutineSnapshot(_ dict: [String: Any]) {
guard let snapshot = dict["routineSnapshot"] as? [String: Any] else { return }
Task { @MainActor in
RoutineStore.shared.apply(snapshot: snapshot)
}
}
private func storeWidgetSnapshot(_ dict: [String: Any]) {
guard let data = dict["widgetSnapshot"] as? Data,
let snapshot = try? JSONDecoder().decode(CoachWidgetSnapshot.self, from: data) else { return }
CoachWidgetStore.save(snapshot)
connLog.info("widgetSnapshot reçu de l'iPhone -> complications rechargées")
DispatchQueue.main.async {
WidgetCenter.shared.reloadAllTimelines()
}
}
}