feat(widgets): widgets natifs iPhone (Séance du jour + Score de forme)

Pont WebView→App Group→WidgetKit :
- CoachWidgetSnapshot.swift : modèle partagé App↔extension (App Group group.ch.hypnotruck.coach)
- CoachWidgetBridge.swift : plugin Capacitor setSnapshot/clear + WidgetCenter.reloadAllTimelines
- CoachWidgets.swift : 2 widgets (TimelineProvider + vues SwiftUI, small/medium)
- bundle + enregistrement plugin + entitlements App Group (App + CoachLiveActivity)

⚠️ Reste câblage Mac (App Group portail + capability + Target Membership) :
docs/widgets-runbook-mac.md. Hook web côté coach_sportif.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-06-29 05:18:17 +00:00
parent edf3783fb5
commit a6bdebcfc8
9 changed files with 466 additions and 2 deletions

View File

@@ -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? // 0100
var label: String? // "Prêt", "Récup", "Fatigue"
}
}