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,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<CoachSnapshotEntry>) -> 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])
}
}