feat(watch): complications Apple Watch (Forme + Séance du jour)

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) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-06-29 05:50:29 +00:00
parent a6bdebcfc8
commit cc88431f6b
5 changed files with 280 additions and 5 deletions

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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() }

View File

@@ -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()
}
}
}

View File

@@ -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<WatchEntry>) -> 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()
}
}