feat(watch): routine quotidienne cochable sur l'Apple Watch

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>
This commit is contained in:
Sylvain Bettinelli
2026-08-03 14:22:33 +00:00
parent 5f8f3edcd9
commit caeac37f38
10 changed files with 639 additions and 0 deletions

View File

@@ -48,6 +48,31 @@ final class ConnectivityManager: NSObject, ObservableObject {
}
}
/// 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 }
@@ -86,10 +111,22 @@ extension ConnectivityManager: WCSessionDelegate {
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]) {