From cc88431f6b7b7d672208003c73acf01f21c5ef04 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Mon, 29 Jun 2026 05:50:29 +0000 Subject: [PATCH] =?UTF-8?q?feat(watch):=20complications=20Apple=20Watch=20?= =?UTF-8?q?(Forme=20+=20S=C3=A9ance=20du=20jour)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'App Group n'étant pas partagé entre appareils, le snapshot transite par WatchConnectivity : iPhone CoachWidgetBridge.pushToWatch (updateApplicationContext) -> CoachWatch/ConnectivityManager reçoit -> App Group de la montre + reload des complications. Nouvelles complications (CoachWatchWidgets/CoachWatchComplications.swift) : Forme (accessoryCircular/corner, Gauge) + Séance du jour (accessoryRectangular/inline). ⚠️ Reste Mac : créer la target widget watchOS + App Group/capability + Target Membership. Runbook complet dans docs/widgets-runbook-mac.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- COWORK.md | 5 +- docs/widgets-runbook-mac.md | 35 ++- ios/App/App/CoachWidgetBridge.swift | 20 +- ios/App/CoachWatch/ConnectivityManager.swift | 23 ++ .../CoachWatchComplications.swift | 202 ++++++++++++++++++ 5 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 ios/App/CoachWatchWidgets/CoachWatchComplications.swift diff --git a/COWORK.md b/COWORK.md index 611b26e..18dc9e8 100644 --- a/COWORK.md +++ b/COWORK.md @@ -25,7 +25,10 @@ Les deux ne communiquent **que via git + ce fichier**. ⚠️ La mémoire de cha - **Capacitor 8 n'auto-découvre PAS les plugins in-app** → tous enregistrés dans `MainViewController.capacitorDidLoad()` : CoachWorkoutKit, CoachHealthRoute, CoachWorkoutObserver, CoachLiveBridge, **CoachAppleAuth**, **CoachGoogleAuth**. - **Login natif Apple + Google = OK, validé device.** Apple via `AuthenticationServices` (intégré). Google via **SDK GoogleSignIn** (SPM, lié à la target App) + `GIDClientID`/URL scheme dans Info.plist. Les deux POSTent l'id_token sur `/auth/apple|google/web` (backend) → cookie de session. - watchOS `CoachWatch` + Live Activity en place (chantiers antérieurs, cf. `HANDOFF-WATCHOS.md`). -- **Widgets natifs iPhone (2026-06-29)** : code écrit côté dev, **reste le câblage Mac**. Plugin `CoachWidgetBridge` (à enregistrer — DÉJÀ ajouté dans `capacitorDidLoad()`), modèle partagé `CoachWidgetSnapshot.swift`, widgets `CoachWidgets.swift` (Séance du jour + Score de forme) dans l'extension `CoachLiveActivity`. App Group `group.ch.hypnotruck.coach` (entitlements écrits). **Hook web déjà en prod** (`widget-bridge.js`). ⚠️ Étapes Mac obligatoires : créer l'App Group sur le portail + activer la capability sur App ET CoachLiveActivity + Target Membership des 3 nouveaux fichiers. **Tout est détaillé dans `docs/widgets-runbook-mac.md`.** +- **Widgets natifs iPhone + complication Watch (2026-06-29)** : code écrit côté dev, **reste le câblage Mac**. + - iPhone : plugin `CoachWidgetBridge` (enregistré dans `capacitorDidLoad()`), modèle partagé `CoachWidgetSnapshot.swift`, widgets `CoachWidgets.swift` (Séance du jour + Score de forme) dans l'extension `CoachLiveActivity`. App Group `group.ch.hypnotruck.coach`. **Hook web déjà en prod** (`widget-bridge.js`). + - 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`.** ## 👉 À 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`. diff --git a/docs/widgets-runbook-mac.md b/docs/widgets-runbook-mac.md index 791a9d4..4d09404 100644 --- a/docs/widgets-runbook-mac.md +++ b/docs/widgets-runbook-mac.md @@ -68,7 +68,36 @@ droite → *Target Membership*. ``` `sport` ∈ running | cycling | strength | mobility | hiking | walking | rest. +## Complication Apple Watch (code écrit — reste la target Xcode) + +⚠️ **Rappel archi** : l'App Group n'est PAS partagé entre iPhone et Watch (conteneurs +distincts). Le flux est : iPhone `CoachWidgetBridge.pushToWatch` → `updateApplicationContext` +→ `CoachWatch/ConnectivityManager` reçoit → écrit dans l'App Group de la MONTRE → +la complication lit ce conteneur. Tout ce code est déjà écrit & committé. + +### Déjà fait (committé) +- iPhone : `CoachWidgetBridge.pushToWatch()` envoie le snapshot via WCSession (sans être delegate). +- Watch : `CoachWatch/ConnectivityManager` gère `didReceiveApplicationContext`/`didReceiveUserInfo` + → `CoachWidgetStore.save` + `WidgetCenter.reloadAllTimelines()`. +- `ios/App/CoachWatchWidgets/CoachWatchComplications.swift` — 2 complications + (Forme : circular/corner ; Séance : rectangular/inline) + `@main` WidgetBundle. + +### Étapes Mac +1. **Créer la target** : File → New → Target → **Widget Extension** (plateforme **watchOS**), + nom `CoachWatchWidgets`, embarquée dans l'app Watch `CoachWatch`. Supprimer le fichier + template généré, puis **Add Files** `CoachWatchComplications.swift` à cette target. +2. **Target Membership de `CoachWidgetSnapshot.swift`** : cocher **CoachWatch** (l'app Watch, + pour `ConnectivityManager`) **ET CoachWatchWidgets** (l'extension complication). +3. **App Group** `group.ch.hypnotruck.coach` : activer la capability sur **CoachWatch** ET + **CoachWatchWidgets** (même ID que l'iPhone ; conteneur physique distinct côté montre). +4. **Build** la target Watch + extension, lancer sur la montre. Ajouter les complications + sur un cadran (Forme = circular ; Séance = rectangular). Ouvrir l'app iPhone une fois + pour pousser le 1er snapshot (l'app Watch doit être installée & appairée). +5. Si la complication reste vide : vérifier que `updateApplicationContext` arrive + (log `widgetSnapshot reçu de l'iPhone` côté watch) et que l'App Group est bien partagé + sur les deux targets watch. + ## Suite possible (backlog) -- Complication **Watch** (nouvelle target widget watchOS — à créer dans Xcode). -- Lockscreen widgets (`accessoryRectangular` / `accessoryCircular`) — ajouter les familles. -- Background refresh (App Background Tasks) si on veut un widget à jour sans ouvrir l'app. +- Lockscreen widgets iPhone (`accessoryRectangular` / `accessoryCircular`) — ajouter les familles aux widgets iOS. +- `transferCurrentComplicationUserInfo` (réveil complication, budget ~50/j) si besoin de fraîcheur en arrière-plan. +- Background refresh iPhone (App Background Tasks) pour un widget à jour sans ouvrir l'app. diff --git a/ios/App/App/CoachWidgetBridge.swift b/ios/App/App/CoachWidgetBridge.swift index 944a8fb..1a891ed 100644 --- a/ios/App/App/CoachWidgetBridge.swift +++ b/ios/App/App/CoachWidgetBridge.swift @@ -15,6 +15,7 @@ import Foundation import Capacitor import WidgetKit +import WatchConnectivity @objc(CoachWidgetBridgePlugin) public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { @@ -42,11 +43,28 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { label: f["label"] as? String ) } - CoachWidgetStore.save(CoachWidgetSnapshot(updatedAt: Date(), today: today, forme: forme)) + let snapshot = CoachWidgetSnapshot(updatedAt: Date(), today: today, forme: forme) + CoachWidgetStore.save(snapshot) if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() } + pushToWatch(snapshot) call.resolve(["ok": true]) } + /// Envoie le snapshot à l'Apple Watch pour ses complications. L'App Group + /// n'est PAS partagé entre appareils → la complication Watch lit le conteneur + /// de SA montre, alimenté ici via WatchConnectivity. + /// On n'est PAS le delegate WCSession (c'est CoachLiveBridge) : on se contente + /// d'émettre, ce qui ne requiert pas d'être delegate, juste une session activée. + private func pushToWatch(_ snapshot: CoachWidgetSnapshot) { + guard WCSession.isSupported() else { return } + let session = WCSession.default + guard session.activationState == .activated, + let data = try? JSONEncoder().encode(snapshot) else { return } + // updateApplicationContext : dernier état, sans budget (≠ transferCurrent + // ComplicationUserInfo qui réveille la complication mais est rationné ~50/j). + try? session.updateApplicationContext(["widgetSnapshot": data]) + } + @objc func clear(_ call: CAPPluginCall) { CoachWidgetStore.clear() if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() } diff --git a/ios/App/CoachWatch/ConnectivityManager.swift b/ios/App/CoachWatch/ConnectivityManager.swift index e1dd9a6..4edf377 100644 --- a/ios/App/CoachWatch/ConnectivityManager.swift +++ b/ios/App/CoachWatch/ConnectivityManager.swift @@ -1,5 +1,6 @@ import Foundation import WatchConnectivity +import WidgetKit import os private let connLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "connectivity") @@ -78,4 +79,26 @@ extension ConnectivityManager: WCSessionDelegate { 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) + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + storeWidgetSnapshot(userInfo) + } + + 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() + } + } } diff --git a/ios/App/CoachWatchWidgets/CoachWatchComplications.swift b/ios/App/CoachWatchWidgets/CoachWatchComplications.swift new file mode 100644 index 0000000..faaacc5 --- /dev/null +++ b/ios/App/CoachWatchWidgets/CoachWatchComplications.swift @@ -0,0 +1,202 @@ +// CoachWatchComplications.swift +// Complications Apple Watch (cible widget extension watchOS « CoachWatchWidgets »). +// Lisent le snapshot poussé par l'iPhone via WatchConnectivity et persisté dans +// l'App Group de CETTE montre (CoachWidgetStore) — cf. ConnectivityManager.swift +// côté CoachWatch + CoachWidgetBridge.pushToWatch côté iPhone. +// +// ⚠️ Cette cible doit être CRÉÉE dans Xcode (Watch Widget Extension) sur le Mac. +// Fichiers à inclure dans sa Target Membership : ce fichier + CoachWidgetSnapshot.swift. +// App Group group.ch.hypnotruck.coach à activer sur CoachWatch ET CoachWatchWidgets. +// Détails : docs/widgets-runbook-mac.md. + +import WidgetKit +import SwiftUI + +// MARK: - Helpers locaux (dupliqués : cible séparée des widgets iOS) + +private func watchSportSymbol(_ sport: String) -> String { + switch sport { + case "cycling": return "bicycle" + case "walking": return "figure.walk" + case "hiking": return "figure.hiking" + case "strength": return "dumbbell.fill" + case "mobility": return "figure.yoga" + case "rest": return "moon.zzz.fill" + default: return "figure.run" + } +} + +private func watchSportLabel(_ sport: String) -> String { + switch sport { + case "running": return "Course" + case "cycling": return "Vélo" + case "walking": return "Marche" + case "hiking": return "Rando" + case "strength": return "Renfo" + case "mobility": return "Mobilité" + case "rest": return "Repos" + default: return sport.capitalized + } +} + +// MARK: - Timeline + +struct WatchEntry: TimelineEntry { + let date: Date + let snapshot: CoachWidgetSnapshot? +} + +struct WatchProvider: TimelineProvider { + func placeholder(in context: Context) -> WatchEntry { + WatchEntry(date: Date(), snapshot: nil) + } + func getSnapshot(in context: Context, completion: @escaping (WatchEntry) -> Void) { + completion(WatchEntry(date: Date(), snapshot: CoachWidgetStore.load())) + } + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = WatchEntry(date: Date(), snapshot: CoachWidgetStore.load()) + let next = Calendar.current.date(byAdding: .hour, value: 2, to: Date()) + ?? Date().addingTimeInterval(7200) + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +private extension View { + @ViewBuilder + func watchWidgetBackground() -> some View { + if #available(watchOS 10.0, *) { + self.containerBackground(.clear, for: .widget) + } else { + self + } + } +} + +// MARK: - Complication « Score de forme » (circular / corner) + +struct CoachWatchFormeView: View { + @Environment(\.widgetFamily) private var family + var entry: WatchEntry + + private var score: Int? { entry.snapshot?.forme?.score } + + private func tint(_ s: Int) -> Color { + switch s { + case 75...: return .green + case 50..<75: return .yellow + default: return .red + } + } + + var body: some View { + Group { + if let s = score { + switch family { + case .accessoryCorner: + Text("\(s)") + .font(.title3.weight(.semibold)) + .widgetCurvesContent(label: "Forme") + default: // accessoryCircular + Gauge(value: Double(s), in: 0...100) { + Image(systemName: "bolt.heart.fill") + } currentValueLabel: { + Text("\(s)") + } + .gaugeStyle(.accessoryCircular) + .tint(tint(s)) + } + } else { + Image(systemName: "bolt.heart") + .font(.headline) + .foregroundStyle(.secondary) + } + } + .watchWidgetBackground() + .widgetURL(URL(string: "https://coach.hypnotruck.ch/forme")) + } +} + +private extension View { + // .widgetLabel pour la famille .accessoryCorner (texte courbé autour du cadran). + @ViewBuilder + func widgetCurvesContent(label: String) -> some View { + self.widgetLabel(label) + } +} + +struct CoachWatchFormeWidget: Widget { + let kind = "CoachWatchFormeWidget" + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in + CoachWatchFormeView(entry: entry) + } + .configurationDisplayName("Forme") + .description("Ton score de forme du jour.") + .supportedFamilies([.accessoryCircular, .accessoryCorner]) + } +} + +// MARK: - Complication « Séance du jour » (rectangular / inline) + +struct CoachWatchSessionView: View { + @Environment(\.widgetFamily) private var family + var entry: WatchEntry + + var body: some View { + let today = entry.snapshot?.today + Group { + if let s = today { + if family == .accessoryInline { + Label(s.title, systemImage: watchSportSymbol(s.sport)) + } else { // accessoryRectangular + VStack(alignment: .leading, spacing: 2) { + Label(watchSportLabel(s.sport).uppercased(), + systemImage: watchSportSymbol(s.sport)) + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + Text(s.title).font(.headline).lineLimit(1) + if let sub = s.subtitle, !sub.isEmpty { + Text(sub).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } else if s.done { + Text("Réalisée").font(.caption2).foregroundStyle(.green) + } + } + } + } else { + if family == .accessoryInline { + Label("Jour OFF", systemImage: "moon.zzz.fill") + } else { + VStack(alignment: .leading, spacing: 2) { + Label("JOUR OFF", systemImage: "moon.zzz.fill") + .font(.caption2.weight(.bold)).foregroundStyle(.secondary) + Text("Pas de séance").font(.footnote) + } + } + } + } + .watchWidgetBackground() + .widgetURL(URL(string: "https://coach.hypnotruck.ch/calendar")) + } +} + +struct CoachWatchSessionWidget: Widget { + let kind = "CoachWatchSessionWidget" + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in + CoachWatchSessionView(entry: entry) + } + .configurationDisplayName("Séance du jour") + .description("La séance prévue aujourd'hui.") + .supportedFamilies([.accessoryRectangular, .accessoryInline]) + } +} + +// MARK: - Bundle @main de l'extension + +@main +struct CoachWatchWidgetsBundle: WidgetBundle { + var body: some Widget { + CoachWatchFormeWidget() + CoachWatchSessionWidget() + } +}