Pendant natif du backend coach_sportif (commit 772385b, déjà en prod). - CoachRoutineBridge (target App) : pousse blocs + état du jour vers la Watch via updateApplicationContext, relaie les coches Watch vers /api/routine/day puis notifie la WebView (event routineUpdated) - CoachWatch/RoutineStore : état persisté en UserDefaults, reset au changement de jour. Les blocs viennent de l'iPhone — rien codé en dur côté watchOS, donc modifier la routine côté web ne demande aucun rebuild - CoachWatch/RoutineView : liste cochable, progression, haptique, bouton de renvoi si la synchro a échoué - ConnectivityManager (watch) : sendRoutine() en sendMessage avec repli transferUserInfo (coche faite iPhone hors de portée -> livraison différée) ⚠️ CoachLiveBridge touché : routeIfNotLiveSample() aiguille les messages routineDone vers NotificationCenter. WCSession.delegate est unique côté iPhone, impossible d'en ajouter un second. Le flux live workout n'est pas modifié mais doit être re-vérifié au build. Les 3 nouveaux fichiers sont référencés dans project.pbxproj (bonne target) : aucun "Add Files" à faire sur le Mac. Runbook : docs/routine-watch-runbook-mac.md NON COMPILÉ — nécessite une session Xcode sur le Mac mini. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
142 lines
5.5 KiB
Swift
142 lines
5.5 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()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
}
|
|
}
|