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

@@ -30,6 +30,27 @@ Les deux ne communiquent **que via git + ce fichier**. ⚠️ La mémoire de cha
- Watch : `CoachWidgetBridge.pushToWatch` (WCSession) → `CoachWatch/ConnectivityManager` reçoit → App Group montre + reload. Complications `CoachWatchWidgets/CoachWatchComplications.swift` (Forme circular/corner, Séance rectangular/inline). **Nouvelle target widget watchOS à créer dans Xcode.**
- ⚠️ Étapes Mac obligatoires (App Group portail + capabilities + Target Membership + target Watch + build) : **TOUT est détaillé dans `docs/widgets-runbook-mac.md`.**
## 🆕 Routine quotidienne sur la Watch (2026-08-03, dev — non compilé)
Nouveau chantier, code complet côté dev, **build Mac requis**. Tout est détaillé
dans **`docs/routine-watch-runbook-mac.md`** (archi, étapes, débogage).
- **Nouveaux fichiers, DÉJÀ référencés dans `project.pbxproj`** (aucun « Add
Files » à faire) : `App/CoachRoutineBridge.swift` (target App),
`CoachWatch/RoutineStore.swift` + `CoachWatch/RoutineView.swift` (target
CoachWatch). Backup : `project.pbxproj.bak-routine`.
- **Modifs** : `MainViewController.capacitorDidLoad()` enregistre
`CoachRoutineBridgePlugin` ; `CoachWatch/ContentView` ouvre `RoutineView` en
sheet ; `ConnectivityManager` (watch) gagne `sendRoutine()` + réception du
`routineSnapshot`.
- ⚠️ **`CoachLiveBridge` touché** — un seul ajout : `routeIfNotLiveSample()`
aiguille les messages `routineDone` vers `NotificationCenter` au lieu de les
émettre comme samples. Le délégué `WCSession` est unique côté iPhone, on ne
pouvait pas en ajouter un second. **À re-vérifier au build** que le live
workout (validé E2E le 31/05) fonctionne toujours.
- Côté backend `coach_sportif` : **déjà en prod** (`/api/routine`,
`/api/routine/check`, `/api/routine/day`, `data/routine_log.json`).
## 👉 À reprendre
-**Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`.
- **iOS — bloqué Mac** : builder + uploader TestFlight le natif accumulé (login Apple+Google natif, watchOS live, HeartRateRangeAlert). Sur le Mac : `cd ~/coach-ios && git pull && npm install && npx cap sync ios && open ios/App/App.xcworkspace` → Clean Build Folder → Archive → Upload.

View File

@@ -0,0 +1,126 @@
# Runbook Mac — Routine quotidienne sur l'Apple Watch
**Écrit côté dev (Linux) le 2026-08-03. Rien n'est compilé.** Ce document dit
quoi faire sur le Mac pour activer la fonctionnalité, et comment la tester.
## Ce que ça fait
La routine anti-douleurs (6 blocs) apparaît sur la montre, cochable au doigt.
Une coche faite sur la Watch remonte à l'iPhone, qui la POSTe au serveur — donc
la page `/routine` et le widget de la home reflètent immédiatement l'état, et
inversement.
## Architecture (à connaître avant de débugger)
```
page /routine (web) Apple Watch (RoutineView)
│ coche │ coche
▼ ▼
POST /api/routine/check ConnectivityManager.sendRoutine
│ │ sendMessage / transferUserInfo
▼ ▼
data/routine_log.json ◄────────── CoachLiveBridge (délégué WCSession)
▲ POST /api/routine/day │ routeIfNotLiveSample()
│ ▼
└─────────────────────── CoachRoutineBridge (NotificationCenter)
│ notifyListeners
routine.js re-render
```
Trois décisions structurantes, à ne pas défaire sans raison :
1. **L'état est serveur, plus en localStorage.** La Watch n'a aucun accès au
localStorage du WKWebView : il fallait un arbitre commun aux trois surfaces.
2. **On transmet l'état COMPLET du jour, jamais un delta.** Si un message
WatchConnectivity se perd, le suivant recale tout au lieu de laisser les deux
côtés diverger. C'est aussi pour ça que `updateApplicationContext` convient :
le système n'en garde que le dernier, ce qui est la sémantique voulue.
3. **`CoachRoutineBridge` n'est PAS `WCSession.delegate`.** `CoachLiveBridge`
l'est déjà et le délégué est unique. `CoachLiveBridge.routeIfNotLiveSample()`
aiguille les messages `routineDone` vers `NotificationCenter` — c'est le seul
endroit où j'ai touché au flux live workout (validé E2E sur device le 31/05),
et il retourne `true` sans rien émettre, donc `LiveStore` n'est pas pollué.
Les blocs eux-mêmes **voyagent avec l'état** : rien n'est codé en dur côté
watchOS. Modifier la routine côté web ne demande donc aucun rebuild.
## Étapes Mac
### 1. Récupérer le code
```bash
# Xcode FERMÉ (règle COWORK)
cd ~/coach-ios && git pull
npm install && npx cap sync ios
open ios/App/App.xcworkspace
```
### 2. Rien à ajouter dans Xcode
Les trois nouveaux fichiers sont **déjà référencés dans `project.pbxproj`**,
dans la bonne target :
| Fichier | Target |
|---|---|
| `App/CoachRoutineBridge.swift` | App |
| `CoachWatch/RoutineStore.swift` | CoachWatch |
| `CoachWatch/RoutineView.swift` | CoachWatch |
Vérification rapide si un doute :
```bash
grep -c "RoutineView.swift" ios/App/App.xcodeproj/project.pbxproj # doit valoir 4
```
(4 = PBXBuildFile + PBXFileReference + children du groupe + Sources phase.)
Backup du pbxproj avant modification : `project.pbxproj.bak-routine`.
### 3. Build
Clean Build Folder (`Cmd+Shift+K`) → Run sur l'iPhone, avec la Watch appairée.
⚠️ `CoachAuth.swift` doit exister (gitignored) : le plugin s'en sert pour
authentifier son POST vers `/api/routine/day`. Sans lui, ça ne compile pas.
### 4. Tester le cycle complet
1. Ouvrir l'app iPhone sur `/routine` — ça pousse les blocs vers la Watch.
2. Sur la Watch : app Coach → **Routine du jour** → la liste des 6 blocs doit
apparaître avec la progression.
3. Cocher un bloc sur la Watch → retour à l'iPhone, la page `/routine` doit se
mettre à jour **sans rechargement** (event `routineUpdated`).
4. Cocher un bloc sur l'iPhone → rouvrir la vue Watch, l'état doit suivre.
5. Vérifier côté serveur :
```bash
ssh ubuntu@83.228.246.229 'cat /home/ubuntu/coach_sportif/data/routine_log.json'
```
### 5. Si la Watch affiche « Ouvre l'app Coach sur l'iPhone »
C'est l'état vide normal au premier lancement : la montre n'a pas encore reçu de
blocs. Si ça persiste après avoir ouvert `/routine` sur l'iPhone :
- console Safari (Développement → iPhone → coach.hypnotruck.ch) :
```js
await Capacitor.Plugins.CoachRoutineBridge.getStatus()
```
→ `paired` et `watchAppInstalled` doivent être `true`.
- Xcode console, filtre `subsystem:ch.hypnotruck.coach category:routine`.
- `updateApplicationContext` échoue silencieusement si la session n'est pas
activée : vérifier que `CoachLiveBridge.load()` a bien tourné (c'est lui qui
active la session).
### 6. Si une coche Watch ne remonte pas
- Hors de portée de l'iPhone, c'est **normal et attendu** : `transferUserInfo`
garantit la livraison différée, la coche partira au prochain rapprochement.
La vue affiche un bouton « Renvoyer vers l'iPhone » si l'envoi a échoué net.
- Sinon : vérifier le log `POST /api/routine/day` côté Xcode (le plugin logue le
code HTTP en cas d'échec — un 401 signifie un `CoachAuth.kCoachWebToken`
périmé).
## Reste à faire (hors périmètre de ce chantier)
- Complication watchOS « Routine » : la target `CoachWatchWidgets` n'existe
toujours pas (cf. `docs/widgets-runbook-mac.md`).
- Notification de rappel sur la montre si la routine n'est pas faite à 20 h.

View File

@@ -11,6 +11,7 @@
0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; };
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; };
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */; };
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A06CDFF5B4F7852B6329862 /* ContentView.swift */; };
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
3E6740C521EF14892687BCF7 /* CoachWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2517374227A4072A4D6AFC15 /* CoachWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -33,6 +34,8 @@
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; };
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */; };
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; };
95D0478A499056FE922211EA /* RoutineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C5902EFB7F60563678E46 /* RoutineView.swift */; };
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969DB880395BDEB107C9F2FE /* RoutineStore.swift */; };
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
@@ -110,8 +113,11 @@
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityManager.swift; sourceTree = "<group>"; };
AA5C5902EFB7F60563678E46 /* RoutineView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineView.swift; sourceTree = "<group>"; };
969DB880395BDEB107C9F2FE /* RoutineStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RoutineStore.swift; sourceTree = "<group>"; };
A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAppleAuth.swift; sourceTree = "<group>"; };
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; };
97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachRoutineBridge.swift; sourceTree = "<group>"; };
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = "<group>"; };
@@ -214,6 +220,7 @@
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */,
97BC6E07E6984342AE95C312 /* CoachRoutineBridge.swift */,
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */,
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */,
@@ -229,6 +236,8 @@
82122ACF7E5A608AD1D55D24 /* Info.plist */,
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */,
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */,
AA5C5902EFB7F60563678E46 /* RoutineView.swift */,
969DB880395BDEB107C9F2FE /* RoutineStore.swift */,
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */,
3342ACCE085010828CF63565 /* Assets.xcassets */,
);
@@ -410,6 +419,7 @@
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */,
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */,
2F86A358498667B66E5EFC60 /* CoachRoutineBridge.swift in Sources */,
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */,
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */,
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */,
@@ -424,6 +434,8 @@
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */,
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */,
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */,
95D0478A499056FE922211EA /* RoutineView.swift in Sources */,
23F47FD81F646516727B5317 /* RoutineStore.swift in Sources */,
436442D6BF54833C59D9E668 /* WorkoutManager.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

@@ -106,15 +106,30 @@ extension CoachLiveBridgePlugin: WCSessionDelegate {
// Temps réel : un sample envoyé via sendMessage.
public func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
if routeIfNotLiveSample(message) { return }
emit(message)
}
// Livraison différée : lot de samples envoyé via transferUserInfo.
public func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
if routeIfNotLiveSample(userInfo) { return }
if let samples = userInfo["samples"] as? [[String: Any]] {
for sample in samples { emit(sample) }
} else {
emit(userInfo)
}
}
/// Aiguillage : ce plugin est l'unique `WCSession.delegate` côté iPhone, il
/// reçoit donc AUSSI les messages qui ne concernent pas la séance live.
/// On les relaie par NotificationCenter au plugin concerné plutôt que de les
/// traiter comme des samples (ce qui polluerait LiveStore).
/// Retourne `true` si le message a été routé ailleurs.
private func routeIfNotLiveSample(_ message: [String: Any]) -> Bool {
guard message["routineDone"] != nil else { return false }
NotificationCenter.default.post(
name: .coachRoutineFromWatch, object: nil, userInfo: message
)
return true
}
}

View File

@@ -0,0 +1,160 @@
import Capacitor
import Foundation
import WatchConnectivity
import os
private let routineLog = Logger(subsystem: "ch.hypnotruck.coach", category: "routine")
/// Nom de notification interne : la Watch a modifié l'état de la routine.
/// `CoachLiveBridgePlugin` (unique `WCSession.delegate` côté iPhone) reçoit le
/// message et le relaie ici plutôt que de l'émettre comme sample de séance.
extension Notification.Name {
static let coachRoutineFromWatch = Notification.Name("coachRoutineFromWatch")
}
/// Pont routine quotidienne iPhone Apple Watch.
///
/// Sens descendant (web Watch) : le JS appelle `pushToWatch` à chaque coche ;
/// on pousse l'état complet du jour via `updateApplicationContext` (le système
/// ne garde que le dernier contexte, ce qui est exactement la sémantique
/// voulue : « voici l'état actuel », pas un journal d'événements).
///
/// Sens montant (Watch serveur) : la Watch envoie son état complet, on le
/// POSTe sur `/api/routine/day` puis on notifie la WebView pour re-render.
/// L'état complet (et non un delta) rend la synchro idempotente : si un message
/// WatchConnectivity se perd, le suivant recale tout.
///
/// Ce plugin n'est PAS `WCSession.delegate` `CoachLiveBridgePlugin` l'est
/// déjà et `WCSession.default.delegate` est unique. Même pattern que
/// `CoachWidgetBridge.pushToWatch`.
@objc(CoachRoutineBridgePlugin)
public class CoachRoutineBridgePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "CoachRoutineBridgePlugin"
public let jsName = "CoachRoutineBridge"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "pushToWatch", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise)
]
private static let apiBase = "https://coach.hypnotruck.ch"
override public func load() {
super.load()
NotificationCenter.default.addObserver(
self,
selector: #selector(handleWatchUpdate(_:)),
name: .coachRoutineFromWatch,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - JS natif
/// `pushToWatch({ date: "2026-08-03", done: ["hanches", ...] })`
@objc func pushToWatch(_ call: CAPPluginCall) {
let date = call.getString("date") ?? Self.todayISO()
let done = call.getArray("done", String.self) ?? []
guard WCSession.isSupported() else {
call.resolve(["pushed": false, "reason": "WCSession non supporté"])
return
}
let session = WCSession.default
guard session.activationState == .activated else {
call.resolve(["pushed": false, "reason": "session non activée"])
return
}
guard session.isPaired, session.isWatchAppInstalled else {
call.resolve(["pushed": false, "reason": "pas de Watch appairée avec l'app"])
return
}
do {
try session.updateApplicationContext([
"routineSnapshot": ["date": date, "done": done]
])
routineLog.info("routine poussée vers la Watch (\(done.count, privacy: .public) blocs)")
call.resolve(["pushed": true, "count": done.count])
} catch {
routineLog.error("updateApplicationContext a échoué : \(error.localizedDescription, privacy: .public)")
call.reject("Envoi vers la Watch impossible : \(error.localizedDescription)")
}
}
@objc func getStatus(_ call: CAPPluginCall) {
let session = WCSession.default
call.resolve([
"supported": WCSession.isSupported(),
"activated": session.activationState == .activated,
"paired": session.isPaired,
"watchAppInstalled": session.isWatchAppInstalled,
"reachable": session.isReachable
])
}
// MARK: - Watch serveur WebView
@objc private func handleWatchUpdate(_ note: Notification) {
guard let payload = note.userInfo as? [String: Any],
let done = payload["routineDone"] as? [String] else { return }
let date = (payload["date"] as? String) ?? Self.todayISO()
routineLog.info("routine reçue de la Watch (\(done.count, privacy: .public) blocs) -> POST serveur")
postDay(date: date, done: done) { [weak self] serverDone in
DispatchQueue.main.async {
// Le serveur fait foi : on renvoie SON état à la WebView, pas
// celui de la Watch (il a pu être filtré côté serveur).
self?.notifyListeners("routineUpdated", data: [
"date": date,
"done": serverDone ?? done
])
}
}
}
/// POST `/api/routine/day` avec le cookie d'auth déjà injecté par AppDelegate.
private func postDay(date: String, done: [String], completion: @escaping ([String]?) -> Void) {
guard let url = URL(string: "\(Self.apiBase)/api/routine/day") else {
completion(nil)
return
}
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie")
req.httpBody = try? JSONSerialization.data(withJSONObject: ["date": date, "done": done])
req.timeoutInterval = 15
URLSession.shared.dataTask(with: req) { data, response, error in
if let error = error {
routineLog.error("POST /api/routine/day : \(error.localizedDescription, privacy: .public)")
completion(nil)
return
}
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
routineLog.error("POST /api/routine/day a répondu \(code, privacy: .public)")
completion(nil)
return
}
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let serverDone = json["done"] as? [String] else {
completion(nil)
return
}
completion(serverDone)
}.resume()
}
private static func todayISO() -> String {
let fmt = DateFormatter()
fmt.dateFormat = "yyyy-MM-dd"
fmt.timeZone = TimeZone.current
return fmt.string(from: Date())
}
}

View File

@@ -31,6 +31,10 @@ class MainViewController: CAPBridgeViewController {
// Pont widgets : la WebView pousse les données du jour vers l'App Group
// et rafraîchit les widgets d'écran d'accueil (cf. CoachWidgetBridge.swift).
bridge?.registerPluginInstance(CoachWidgetBridgePlugin())
// Pont routine quotidienne : pousse les blocs + l'état du jour vers
// l'app watchOS, et relaie les coches faites sur la montre vers
// /api/routine/day (cf. CoachRoutineBridge.swift).
bridge?.registerPluginInstance(CoachRoutineBridgePlugin())
}
override func viewDidLoad() {

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]) {

View File

@@ -24,6 +24,7 @@ struct ContentView: View {
struct ActivityPickerView: View {
@EnvironmentObject private var workout: WorkoutManager
@State private var showRoutine = false
private struct Activity: Identifiable {
let id = UUID()
@@ -54,6 +55,14 @@ struct ActivityPickerView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Button {
showRoutine = true
} label: {
Label("Routine du jour", systemImage: "figure.cooldown")
.frame(maxWidth: .infinity, alignment: .leading)
}
.tint(.cyan)
Button {
workout.startSimulation()
} label: {
@@ -71,6 +80,9 @@ struct ActivityPickerView: View {
.padding(.horizontal, 4)
}
.navigationTitle("Coach")
.sheet(isPresented: $showRoutine) {
RoutineView()
}
}
}

View File

@@ -0,0 +1,134 @@
import Foundation
import SwiftUI
import os
private let routineLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "routine")
/// Un bloc de la routine quotidienne, tel que poussé par l'iPhone.
struct RoutineBlock: Codable, Identifiable, Equatable {
let id: String
let num: Int
let title: String
let durationMin: Int
let moment: String // "matin" | "après la course"
enum CodingKeys: String, CodingKey {
case id, num, title
case durationMin = "duration_min"
case moment
}
}
/// État de la routine sur la montre.
///
/// Les blocs ne sont PAS codés en dur ici : ils viennent de l'iPhone (qui les
/// tient du serveur). Une seule source de vérité modifier la routine côté web
/// n'oblige pas à rebuilder l'app Watch.
///
/// Persisté dans UserDefaults pour survivre au relancement de l'app Watch : sans
/// ça, la montre afficherait un écran vide tant que l'iPhone n'a pas repoussé
/// le contexte (ce qui peut prendre plusieurs minutes hors de portée).
@MainActor
final class RoutineStore: ObservableObject {
static let shared = RoutineStore()
@Published private(set) var blocks: [RoutineBlock] = []
@Published private(set) var done: Set<String> = []
@Published private(set) var date: String = ""
@Published private(set) var lastSyncFailed = false
private let defaults = UserDefaults.standard
private let kBlocks = "routine.blocks"
private let kDone = "routine.done"
private let kDate = "routine.date"
private init() {
load()
}
var isEmpty: Bool { blocks.isEmpty }
var morningBlocks: [RoutineBlock] { blocks.filter { $0.moment == "matin" } }
var otherBlocks: [RoutineBlock] { blocks.filter { $0.moment != "matin" } }
// MARK: - Persistance locale
private func load() {
if let data = defaults.data(forKey: kBlocks),
let decoded = try? JSONDecoder().decode([RoutineBlock].self, from: data) {
blocks = decoded
}
done = Set(defaults.stringArray(forKey: kDone) ?? [])
date = defaults.string(forKey: kDate) ?? Self.todayISO()
resetIfStale()
}
private func persist() {
if let data = try? JSONEncoder().encode(blocks) {
defaults.set(data, forKey: kBlocks)
}
defaults.set(Array(done), forKey: kDone)
defaults.set(date, forKey: kDate)
}
/// Changement de jour : les coches d'hier ne doivent pas traîner.
private func resetIfStale() {
let today = Self.todayISO()
if date != today {
date = today
done = []
persist()
}
}
// MARK: - iPhone Watch
func apply(snapshot: [String: Any]) {
if let blocksRaw = snapshot["blocks"] as? [[String: Any]],
let data = try? JSONSerialization.data(withJSONObject: blocksRaw),
let decoded = try? JSONDecoder().decode([RoutineBlock].self, from: data) {
blocks = decoded
}
if let snapDate = snapshot["date"] as? String {
date = snapDate
}
if let doneIds = snapshot["done"] as? [String] {
done = Set(doneIds)
}
lastSyncFailed = false
persist()
routineLog.info("routine reçue de l'iPhone : \(self.blocks.count, privacy: .public) blocs, \(self.done.count, privacy: .public) faits")
}
// MARK: - Watch iPhone
func toggle(_ blockId: String) {
resetIfStale()
if done.contains(blockId) {
done.remove(blockId)
} else {
done.insert(blockId)
}
persist()
push()
}
/// Renvoie l'état COMPLET (pas un delta) : si un message se perd, le suivant
/// recale tout au lieu de laisser les deux côtés diverger.
private func push() {
let sent = ConnectivityManager.shared.sendRoutine(date: date, done: Array(done))
lastSyncFailed = !sent
}
/// Nouvelle tentative manuelle après un échec (bouton de la vue).
func retry() {
push()
}
static func todayISO() -> String {
let fmt = DateFormatter()
fmt.dateFormat = "yyyy-MM-dd"
fmt.timeZone = TimeZone.current
return fmt.string(from: Date())
}
}

View File

@@ -0,0 +1,118 @@
import SwiftUI
import WatchKit
/// Routine quotidienne sur la montre : liste des blocs, cochables au doigt.
/// Chaque coche part vers l'iPhone, qui la POSTe au serveur l'état est donc
/// partagé avec la page /routine et le widget de la home.
struct RoutineView: View {
@StateObject private var store = RoutineStore.shared
@Environment(\.dismiss) private var dismiss
var body: some View {
Group {
if store.isEmpty {
emptyState
} else {
list
}
}
.navigationTitle("Routine")
}
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "figure.cooldown")
.font(.title2)
.foregroundStyle(.secondary)
Text("Ouvre l'app Coach sur l'iPhone une fois pour recevoir la routine.")
.font(.footnote)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding()
}
private var list: some View {
ScrollView {
VStack(spacing: 6) {
progressHeader
if !store.morningBlocks.isEmpty {
sectionTitle("Le matin")
ForEach(store.morningBlocks) { block in
row(block)
}
}
if !store.otherBlocks.isEmpty {
sectionTitle("Après la course")
ForEach(store.otherBlocks) { block in
row(block)
}
}
if store.lastSyncFailed {
Button {
store.retry()
} label: {
Label("Renvoyer vers l'iPhone", systemImage: "arrow.clockwise")
.font(.footnote)
}
.tint(.orange)
.padding(.top, 4)
}
}
.padding(.horizontal, 4)
}
}
private var progressHeader: some View {
let total = store.blocks.count
let count = store.done.count
return VStack(spacing: 4) {
Text("\(count) / \(total) blocs")
.font(.headline)
.foregroundStyle(count == total && total > 0 ? .green : .primary)
ProgressView(value: total > 0 ? Double(count) / Double(total) : 0)
.tint(.cyan)
}
.padding(.vertical, 4)
}
private func sectionTitle(_ text: String) -> some View {
Text(text.uppercased())
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 6)
}
private func row(_ block: RoutineBlock) -> some View {
let isDone = store.done.contains(block.id)
return Button {
store.toggle(block.id)
WKInterfaceDevice.current().play(isDone ? .click : .success)
} label: {
HStack(spacing: 8) {
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isDone ? .green : .secondary)
VStack(alignment: .leading, spacing: 1) {
Text(block.title)
.font(.footnote)
.multilineTextAlignment(.leading)
.strikethrough(isDone, color: .secondary)
Text("\(block.durationMin) min")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.padding(.vertical, 6)
.padding(.horizontal, 8)
.background(Color.gray.opacity(isDone ? 0.10 : 0.18), in: RoundedRectangle(cornerRadius: 8))
.opacity(isDone ? 0.65 : 1)
}
}