Files
coach-ios/ios/App/CoachWatch/ConnectivityManager.swift
Sylvain Bettinelli 852e12526a La montre avait les données du jour et n'en montrait rien
Le snapshot poussé par l'iPhone (séance prévue, forme, hydratation, énergie,
routine, séance de demain) arrive sur la montre depuis WatchConnectivity et
vit dans son App Group. Les complications le lisent ; l'app, elle, ouvrait
sur le lanceur d'activité et n'en affichait pas une ligne.

Aucun nouveau pont : `ConnectivityManager` publie le snapshot qu'il persistait
déjà, l'accueil en montre un résumé, et une feuille « Aujourd'hui » donne le
détail. Même `asOf(_:)` que les complications, pour qu'un snapshot d'hier se
vide au lieu de mentir.

Les vues vivent dans ContentView.swift, déjà membre de la cible CoachWatch :
pas de Target Membership à régler dans Xcode.

⚠️ Non compilé — SwiftUI et WatchKit n'existent pas sur Linux. Seuls
`swiftc -parse` et les tests Linux existants sont passés. À builder sur le Mac.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:34:31 +00:00

257 lines
11 KiB
Swift
Raw Permalink 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?
/// Dernier snapshot du jour poussé par l'iPhone : séance prévue, score de
/// forme, hydratation, énergie, routine.
///
/// Ces données arrivaient déjà sur la montre les complications les lisent
/// depuis le cadran mais rien ne les publiait, donc l'app elle-même n'en
/// montrait rien. Publié pour la même raison que `zones` : décoder le JSON
/// de l'App Group à chaque rendu coûterait pour rien.
@Published private(set) var snapshot: CoachWidgetSnapshot?
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.
let stored = CoachWidgetStore.load()
zones = stored?.zones
snapshot = stored
}
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 }
self.snapshot = snapshot
WidgetCenter.shared.reloadAllTimelines()
}
}
}