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() } } }