diff --git a/COWORK.md b/COWORK.md index 8ddaf7a..611b26e 100644 --- a/COWORK.md +++ b/COWORK.md @@ -25,6 +25,7 @@ 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`.** ## 👉 À 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 new file mode 100644 index 0000000..791a9d4 --- /dev/null +++ b/docs/widgets-runbook-mac.md @@ -0,0 +1,74 @@ +# Runbook Mac — widgets natifs iPhone (Séance du jour + Score de forme) + +**Pour le Claude « Cowork » (Mac) / Sylvain.** Le code Swift + entitlements + le +hook web sont écrits côté `coach_sportif`/`coach-ios` par le Claude dev (Linux). +Il **reste les étapes Mac/portail Apple** ci-dessous — elles ne peuvent pas se +faire depuis Linux (provisioning + ajout de fichiers aux cibles dans Xcode). + +## Ce qui est déjà fait (committé) + +**coach-ios** (natif) : +- `ios/App/App/CoachWidgetSnapshot.swift` — modèle partagé App ↔ extension (App Group). +- `ios/App/App/CoachWidgetBridge.swift` — plugin Capacitor `setSnapshot`/`clear` + `WidgetCenter.reloadAllTimelines()`. +- `ios/App/CoachLiveActivity/CoachWidgets.swift` — 2 widgets (TimelineProvider + vues SwiftUI). +- `ios/App/CoachLiveActivity/CoachLiveActivityBundle.swift` — widgets ajoutés au `WidgetBundle`. +- `ios/App/App/MainViewController.swift` — plugin enregistré dans `capacitorDidLoad()`. +- `ios/App/App/App.entitlements` — App Group `group.ch.hypnotruck.coach` ajouté. +- `ios/App/CoachLiveActivity/CoachLiveActivity.entitlements` — **créé** avec l'App Group. + +**coach_sportif** (web, déjà en prod via webhook) : +- `web/static/widget-bridge.js` + include dans `_layout.html` — pousse `today` (depuis `/api/today`) + `forme` (recovery_score). No-op hors app native. +- `web/templates/home.html` — `data-form-score` exposé sur le hero. + +## Étapes Mac à faire (dans l'ordre) + +### 1. App Group sur le portail Apple +developer.apple.com → Certificates, IDs & Profiles → **Identifiers** → **App Groups** → +créer `group.ch.hypnotruck.coach` (si pas déjà créé). + +### 2. Activer la capability sur les 2 cibles (Xcode) +⚠️ **Quitter puis `git pull`** d'abord (règle COWORK : jamais de pull Xcode ouvert). +Ouvrir `ios/App/App.xcworkspace`, puis pour **App** ET **CoachLiveActivity** : +- onglet **Signing & Capabilities** → **+ Capability** → **App Groups** → + cocher `group.ch.hypnotruck.coach`. +- Vérifier que `CODE_SIGN_ENTITLEMENTS` pointe bien sur le bon `.entitlements` + (App → `App/App.entitlements` ; CoachLiveActivity → `CoachLiveActivity/CoachLiveActivity.entitlements`, + à régler si Xcode ne l'a pas pris). + +### 3. Ajouter les nouveaux fichiers aux bonnes cibles (Target Membership) +Les fichiers existent sur disque mais doivent être référencés par le projet : +- `CoachWidgetSnapshot.swift` → **cocher App ET CoachLiveActivity** (fichier partagé). +- `CoachWidgetBridge.swift` → **cible App** uniquement. +- `CoachWidgets.swift` → **cible CoachLiveActivity** uniquement. +- `CoachLiveActivity.entitlements` → pas une source (référencé par le build setting). + +Méthode : clic droit sur le dossier de la cible → *Add Files to "App"…* (décocher +*Copy items*, cocher la bonne cible), ou sélectionner le fichier → inspecteur de +droite → *Target Membership*. + +### 4. Build + test device +- **Clean Build Folder** (`Cmd+Shift+K`) → **Run** sur l'iPhone. +- Ouvrir l'app (charge la prod) une fois pour que `widget-bridge.js` pousse le snapshot. +- Écran d'accueil → ajouter les widgets **Coach** : « Séance du jour » (small/medium) et « Score de forme » (small). +- Vérifier qu'ils affichent les vraies données du jour. Tap → ouvre `/calendar` resp. `/forme`. + +### 5. Debug si le widget reste vide +- Vérifier le snapshot écrit : Safari → Développement → iPhone → console → + `await Capacitor.Plugins.CoachWidgetBridge.setSnapshot({today:{sport:"running",title:"Test",done:false}})` + puis long-press l'écran d'accueil → le widget doit se mettre à jour. +- Si rien : l'App Group n'est pas partagé (étape 2) → `UserDefaults(suiteName:)` renvoie nil. + +## Modèle de données (contrat web ↔ widget) + +```jsonc +{ + "today": { "sport": "running", "title": "Sortie longue", "subtitle": "90 min · Z2", "done": false }, + "forme": { "score": 72, "label": "Prêt" } // null si pas de score +} +``` +`sport` ∈ running | cycling | strength | mobility | hiking | walking | rest. + +## 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. diff --git a/ios/App/App/App.entitlements b/ios/App/App/App.entitlements index 5aacad2..fb4b48c 100644 --- a/ios/App/App/App.entitlements +++ b/ios/App/App/App.entitlements @@ -12,5 +12,9 @@ Default + com.apple.security.application-groups + + group.ch.hypnotruck.coach + diff --git a/ios/App/App/CoachWidgetBridge.swift b/ios/App/App/CoachWidgetBridge.swift new file mode 100644 index 0000000..944a8fb --- /dev/null +++ b/ios/App/App/CoachWidgetBridge.swift @@ -0,0 +1,55 @@ +// CoachWidgetBridge.swift +// Plugin Capacitor (cible App) : la WebView pousse les données du jour +// (séance + score de forme) vers l'App Group, puis on rafraîchit les widgets +// d'écran d'accueil. Aucun accès réseau côté widget : tout passe par ce pont. +// +// Enregistrement : MainViewController.capacitorDidLoad() -> +// bridge?.registerPluginInstance(CoachWidgetBridgePlugin()) +// +// Usage JS (no-op gracieux hors app native) : +// const W = window.Capacitor?.Plugins?.CoachWidgetBridge; +// await W?.setSnapshot({ +// today: { sport:"running", title:"Sortie longue", subtitle:"1h30 · Z2", done:false }, +// forme: { score: 72, label:"Prêt" } +// }); +import Foundation +import Capacitor +import WidgetKit + +@objc(CoachWidgetBridgePlugin) +public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "CoachWidgetBridgePlugin" + public let jsName = "CoachWidgetBridge" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "setSnapshot", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise), + ] + + @objc func setSnapshot(_ call: CAPPluginCall) { + var today: CoachWidgetSnapshot.TodaySession? + if let t = call.getObject("today") { + today = CoachWidgetSnapshot.TodaySession( + sport: t["sport"] as? String ?? "rest", + title: t["title"] as? String ?? "", + subtitle: t["subtitle"] as? String, + done: t["done"] as? Bool ?? false + ) + } + var forme: CoachWidgetSnapshot.FormeScore? + if let f = call.getObject("forme") { + forme = CoachWidgetSnapshot.FormeScore( + score: f["score"] as? Int, + label: f["label"] as? String + ) + } + CoachWidgetStore.save(CoachWidgetSnapshot(updatedAt: Date(), today: today, forme: forme)) + if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() } + call.resolve(["ok": true]) + } + + @objc func clear(_ call: CAPPluginCall) { + CoachWidgetStore.clear() + if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() } + call.resolve(["ok": true]) + } +} diff --git a/ios/App/App/CoachWidgetSnapshot.swift b/ios/App/App/CoachWidgetSnapshot.swift new file mode 100644 index 0000000..393d594 --- /dev/null +++ b/ios/App/App/CoachWidgetSnapshot.swift @@ -0,0 +1,56 @@ +// CoachWidgetSnapshot.swift +// Modèle de données PARTAGÉ entre l'app (cible App) et l'extension widget +// (cible CoachLiveActivity). L'app écrit le snapshot du jour dans l'App Group +// via CoachWidgetBridge ; les widgets d'écran d'accueil le lisent. +// +// ⚠️ Ce fichier doit appartenir aux DEUX cibles (App + CoachLiveActivity) — +// cf. docs/widgets-runbook-mac.md (étape « Target Membership »). +// +// App Group requis : group.ch.hypnotruck.coach (à créer sur le portail Apple +// et activer comme capability sur les deux cibles). +import Foundation + +enum CoachWidgetStore { + /// Identifiant App Group partagé app ↔ extension. Doit correspondre + /// EXACTEMENT à la capability "App Groups" des deux cibles. + static let appGroup = "group.ch.hypnotruck.coach" + static let snapshotKey = "coach_widget_snapshot" + + static var defaults: UserDefaults? { UserDefaults(suiteName: appGroup) } + + static func save(_ snapshot: CoachWidgetSnapshot) { + guard let d = defaults, let data = try? JSONEncoder().encode(snapshot) else { return } + d.set(data, forKey: snapshotKey) + } + + static func load() -> CoachWidgetSnapshot? { + guard let d = defaults, let data = d.data(forKey: snapshotKey) else { return nil } + return try? JSONDecoder().decode(CoachWidgetSnapshot.self, from: data) + } + + static func clear() { + defaults?.removeObject(forKey: snapshotKey) + } +} + +/// Instantané des données affichées par les widgets. Volontairement minimal : +/// le widget n'a pas d'accès réseau, tout vient de ce snapshot poussé par l'app. +struct CoachWidgetSnapshot: Codable { + var updatedAt: Date + var today: TodaySession? + var forme: FormeScore? + + /// Séance planifiée du jour (nil = jour OFF / pas de séance). + struct TodaySession: Codable { + var sport: String // running, cycling, strength, mobility, hiking, rest… + var title: String // "Sortie longue", "Renfo bas du corps"… + var subtitle: String? // objectif court : "1h30 · Z2", "4×12"… + var done: Bool // séance déjà réalisée aujourd'hui + } + + /// Score de forme / récupération (nil = pas encore de score). + struct FormeScore: Codable { + var score: Int? // 0–100 + var label: String? // "Prêt", "Récup", "Fatigue"… + } +} diff --git a/ios/App/App/MainViewController.swift b/ios/App/App/MainViewController.swift index 24c68c2..0bc0009 100644 --- a/ios/App/App/MainViewController.swift +++ b/ios/App/App/MainViewController.swift @@ -28,6 +28,9 @@ class MainViewController: CAPBridgeViewController { // Plugin natif Sign in with Google — signIn() via SDK GoogleSignIn // → id_token POSTé sur /auth/google/web (cf. CoachGoogleAuth.swift). bridge?.registerPluginInstance(CoachGoogleAuthPlugin()) + // 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()) } override func viewDidLoad() { diff --git a/ios/App/CoachLiveActivity/CoachLiveActivity.entitlements b/ios/App/CoachLiveActivity/CoachLiveActivity.entitlements new file mode 100644 index 0000000..7072a81 --- /dev/null +++ b/ios/App/CoachLiveActivity/CoachLiveActivity.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.ch.hypnotruck.coach + + + diff --git a/ios/App/CoachLiveActivity/CoachLiveActivityBundle.swift b/ios/App/CoachLiveActivity/CoachLiveActivityBundle.swift index 46c67ad..c344775 100644 --- a/ios/App/CoachLiveActivity/CoachLiveActivityBundle.swift +++ b/ios/App/CoachLiveActivity/CoachLiveActivityBundle.swift @@ -1,6 +1,7 @@ // CoachLiveActivityBundle.swift -// Entry point @main de l'extension WidgetKit. Contient le ou les Widgets -// exposés par cette extension. Ici juste la Live Activity du live workout. +// Entry point @main de l'extension WidgetKit. Contient les Widgets exposés par +// cette extension : la Live Activity du live workout + les widgets d'écran +// d'accueil (séance du jour, score de forme). import WidgetKit import SwiftUI @@ -9,5 +10,7 @@ import SwiftUI struct CoachLiveActivityBundle: WidgetBundle { var body: some Widget { CoachLiveActivityWidget() + CoachTodaySessionWidget() + CoachFormeScoreWidget() } } diff --git a/ios/App/CoachLiveActivity/CoachWidgets.swift b/ios/App/CoachLiveActivity/CoachWidgets.swift new file mode 100644 index 0000000..5634bf8 --- /dev/null +++ b/ios/App/CoachLiveActivity/CoachWidgets.swift @@ -0,0 +1,258 @@ +// CoachWidgets.swift +// Widgets d'écran d'accueil (cible CoachLiveActivity). Lisent le snapshot +// poussé par l'app dans l'App Group (CoachWidgetStore) — aucun accès réseau. +// Deux widgets : « Séance du jour » et « Score de forme ». +// +// ⚠️ Ce fichier appartient à la cible CoachLiveActivity. Il référence +// CoachWidgetSnapshot.swift, qui doit AUSSI appartenir à cette cible +// (Target Membership) — cf. docs/widgets-runbook-mac.md. + +import WidgetKit +import SwiftUI + +// MARK: - Timeline + +struct CoachSnapshotEntry: TimelineEntry { + let date: Date + let snapshot: CoachWidgetSnapshot? +} + +struct CoachWidgetProvider: TimelineProvider { + func placeholder(in context: Context) -> CoachSnapshotEntry { + CoachSnapshotEntry(date: Date(), snapshot: nil) + } + + func getSnapshot(in context: Context, completion: @escaping (CoachSnapshotEntry) -> Void) { + completion(CoachSnapshotEntry(date: Date(), snapshot: CoachWidgetStore.load())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = CoachSnapshotEntry(date: Date(), snapshot: CoachWidgetStore.load()) + // L'app rafraîchit explicitement (WidgetCenter.reloadAllTimelines) à + // chaque setSnapshot. On planifie en plus un refresh horaire de courtoisie. + let next = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) + ?? Date().addingTimeInterval(3600) + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +// MARK: - Palette / icônes (alignées sur les couleurs sport de l'app) + +private func sportColor(_ sport: String) -> Color { + switch sport { + case "running": return Color(red: 1.00, green: 0.45, blue: 0.21) + case "cycling": return Color(red: 0.20, green: 0.78, blue: 0.35) + case "strength": return Color(red: 0.69, green: 0.32, blue: 0.87) + case "mobility": return Color(red: 0.35, green: 0.78, blue: 0.98) + case "hiking", "walking": return Color(red: 0.80, green: 0.62, blue: 0.30) + default: return Color.gray + } +} + +private func sportSymbol(_ 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 sportLabel(_ 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 + } +} + +// Applique le fond container (requis iOS 17+) sans casser le build < 17. +private extension View { + @ViewBuilder + func coachWidgetBackground() -> some View { + if #available(iOS 17.0, *) { + self.containerBackground(.fill.tertiary, for: .widget) + } else { + self.padding() + } + } +} + +// MARK: - Widget « Séance du jour » + +struct CoachTodaySessionEntryView: View { + var entry: CoachSnapshotEntry + @Environment(\.widgetFamily) private var family + + var body: some View { + let today = entry.snapshot?.today + Group { + if let s = today { + if family == .systemMedium { + mediumView(s) + } else { + smallView(s) + } + } else { + emptyView + } + } + .coachWidgetBackground() + .widgetURL(URL(string: "https://coach.hypnotruck.ch/calendar")) + } + + private func header(_ s: CoachWidgetSnapshot.TodaySession) -> some View { + HStack(spacing: 6) { + Image(systemName: sportSymbol(s.sport)) + .foregroundStyle(sportColor(s.sport)) + Text(sportLabel(s.sport).uppercased()) + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + Spacer() + if s.done { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(.green) + .font(.caption) + } + } + } + + private func smallView(_ s: CoachWidgetSnapshot.TodaySession) -> some View { + VStack(alignment: .leading, spacing: 6) { + header(s) + Spacer(minLength: 0) + Text(s.title) + .font(.headline) + .lineLimit(2) + .minimumScaleFactor(0.8) + if let sub = s.subtitle, !sub.isEmpty { + Text(sub).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + private func mediumView(_ s: CoachWidgetSnapshot.TodaySession) -> some View { + HStack(spacing: 14) { + ZStack { + Circle().fill(sportColor(s.sport).opacity(0.18)) + Image(systemName: sportSymbol(s.sport)) + .font(.title2) + .foregroundStyle(sportColor(s.sport)) + } + .frame(width: 56, height: 56) + VStack(alignment: .leading, spacing: 4) { + Text("Séance du jour") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + Text(s.title).font(.headline).lineLimit(2) + if let sub = s.subtitle, !sub.isEmpty { + Text(sub).font(.subheadline).foregroundStyle(.secondary).lineLimit(1) + } + if s.done { + Label("Réalisée", systemImage: "checkmark.seal.fill") + .font(.caption2).foregroundStyle(.green) + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + private var emptyView: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "moon.zzz.fill").foregroundStyle(.secondary) + Text("JOUR OFF").font(.caption2.weight(.bold)).foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Text("Pas de séance prévue") + .font(.subheadline).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } +} + +struct CoachTodaySessionWidget: Widget { + let kind = "CoachTodaySessionWidget" + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in + CoachTodaySessionEntryView(entry: entry) + } + .configurationDisplayName("Séance du jour") + .description("La séance prévue aujourd'hui dans ton programme.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +// MARK: - Widget « Score de forme » + +struct CoachFormeScoreEntryView: View { + var entry: CoachSnapshotEntry + + private func ringColor(_ score: Int) -> Color { + switch score { + case 75...: return Color(red: 0.20, green: 0.78, blue: 0.35) + case 50..<75: return Color(red: 1.00, green: 0.80, blue: 0.0) + default: return Color(red: 1.00, green: 0.27, blue: 0.23) + } + } + + var body: some View { + let forme = entry.snapshot?.forme + VStack(spacing: 8) { + Text("FORME") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + Spacer(minLength: 0) + if let score = forme?.score { + ZStack { + Circle().stroke(Color.secondary.opacity(0.18), lineWidth: 9) + Circle() + .trim(from: 0, to: min(max(Double(score) / 100.0, 0), 1)) + .stroke(ringColor(score), style: StrokeStyle(lineWidth: 9, lineCap: .round)) + .rotationEffect(.degrees(-90)) + VStack(spacing: 0) { + Text("\(score)").font(.system(size: 30, weight: .bold, design: .rounded)) + if let l = forme?.label, !l.isEmpty { + Text(l).font(.caption2).foregroundStyle(.secondary) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + VStack(spacing: 4) { + Image(systemName: "bolt.heart") + .font(.title) + .foregroundStyle(.secondary) + Text("Pas de score") + .font(.caption).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .coachWidgetBackground() + .widgetURL(URL(string: "https://coach.hypnotruck.ch/forme")) + } +} + +struct CoachFormeScoreWidget: Widget { + let kind = "CoachFormeScoreWidget" + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in + CoachFormeScoreEntryView(entry: entry) + } + .configurationDisplayName("Score de forme") + .description("Ton score de forme / récupération du jour.") + .supportedFamilies([.systemSmall]) + } +}