Files
coach-ios/ios/App/CoachWatch/ConnectivityManager.swift
Sylvain Bettinelli eb819b68ca Watch : afficher la zone cardiaque pendant la séance
L'app Coach de la montre n'affichait que la FC brute — aucune notion de zone
dans `ContentView.swift`. C'est pourtant au poignet que « 142 bpm » a besoin
d'être traduit ; l'écran iPhone portait déjà l'information, mais on ne le
regarde pas en courant.

- `HeartRateZones.swift` : modèle partagé (App + CoachLiveActivity + les deux
  cibles Watch), câblé dans les 4 phases Sources du pbxproj comme
  `CoachWidgetSnapshot.swift`. ⚠️ Les bornes viennent de `/api/cardiac-zones`,
  version FIGÉE et datée — jamais recalculées côté natif, ce serait rouvrir les
  jeux de zones concurrents supprimés le 12/08.
- `zone(for:)` retient la zone la plus HAUTE qui contient la valeur : les
  bornes se chevauchent d'un battement (Z1 100-109, Z2 109-118), et 109 bpm
  doit se lire Z2, pas Z1. Au-delà du haut de Z5, on reste en Z5 — un effort
  maximal n'est pas une absence de zone.
- `ConnectivityManager` publie les zones reçues avec le snapshot et repart du
  dernier connu au lancement (la montre a pu redémarrer). Un snapshot partiel
  n'efface pas celles en place.
- `HeartRateMetric` : pastille « Z3 », valeur colorée, et la plage sous le
  chiffre. ⚠️ La couleur ne porte jamais l'information seule — illisible pour
  un daltonien, et le contraste d'un écran de montre en plein soleil ne se
  prête pas aux nuances. Libellé VoiceOver explicite.

`switch` en `return` explicites plutôt qu'en expression : la syntaxe courte
exige Swift 5.9 pile, et je ne peux pas compiler ici.

⚠️ NON COMPILÉ — build Xcode requis, avec `getSnapshot` (`8a25f36`) en attente.
2026-08-12 19:13:07 +00:00

245 lines
10 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
/// Zones cardiaques en vigueur, poussées par l'iPhone avec le snapshot des
/// complications. Sert à traduire la FC en zone pendant la séance.
///
/// Publiée depuis `ConnectivityManager` plutôt que lue à chaque rendu :
/// `CoachWidgetStore.load()` décode du JSON, l'appeler dans un `body`
/// rafraîchi à chaque battement coûterait pour rien.
@Published private(set) var zones: HeartRateZones?
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()
// La montre a pu redémarrer depuis le dernier envoi de l'iPhone : on
// repart du dernier snapshot connu plutôt que d'attendre le suivant.
zones = CoachWidgetStore.load()?.zones
}
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
}
/// Envoie le résultat brut d'un test de VMA (paliers tenus ou distance).
///
/// Livraison différée garantie comme la routine : un test se fait dehors,
/// l'iPhone peut être hors de portée, et refaire un test maximal parce que
/// le message s'est perdu n'est pas acceptable. Le calcul de la VMA se fait
/// côté serveur la montre ne transmet que des mesures.
@discardableResult
func sendVmaResult(_ payload: [String: Any]) -> Bool {
let session = WCSession.default
guard session.activationState == .activated else { return false }
if session.isReachable {
session.sendMessage(payload, replyHandler: nil) { _ in
session.transferUserInfo(payload)
}
} else {
session.transferUserInfo(payload)
}
connLog.info("résultat de test VMA envoyé à l'iPhone")
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 {
// Des zones absentes d'un snapshot partiel ne doivent pas effacer
// celles en place : la séance en cours perdrait son repère.
if let received = snapshot.zones { self.zones = received }
WidgetCenter.shared.reloadAllTimelines()
}
}
}