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