// 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) { let now = Date() completion(CoachSnapshotEntry(date: now, snapshot: CoachWidgetStore.load()?.asOf(now))) } /// ⚠️ **DEUX entrées, et c'est le correctif.** /// /// L'ancienne version n'en produisait qu'une, avec le snapshot brut, et /// repassait une heure plus tard. Or repasser ne sert à rien : elle relisait /// le MÊME snapshot périmé dans l'App Group et le réaffichait à l'identique. /// Les chiffres de la veille restaient donc à l'écran jusqu'à ce que l'app /// soit rouverte à la main. /// /// La seconde entrée est datée de minuit et porte le snapshot passé par /// `asOf(_:)`. WidgetKit bascule dessus tout seul à l'heure dite, **sans /// réveiller l'app ni accéder au réseau** — ce qu'un widget ne peut de /// toute façon pas faire. func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let now = Date() let stored = CoachWidgetStore.load() let midnight = CoachWidgetSnapshot.nextMidnight(after: now) let entries = [ CoachSnapshotEntry(date: now, snapshot: stored?.asOf(now)), CoachSnapshotEntry(date: midnight, snapshot: stored?.asOf(midnight)), ] // L'app rafraîchit explicitement (WidgetCenter.reloadAllTimelines) à // chaque setSnapshot ; on redemande la main peu après minuit pour que // le jour d'APRÈS soit à son tour préparé. completion(Timeline(entries: entries, policy: .after(midnight.addingTimeInterval(60)))) } } // 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) } /// Rien au plan. Deux situations très différentes, longtemps confondues : /// une vraie journée de repos, et une journée où le plan a été vidé parce /// que l'activité a été faite autrement. Annoncer « JOUR OFF » à qui vient /// de rouler 20 km efface son effort — d'où la bascule sur `doneToday`. @ViewBuilder private var emptyView: some View { if let d = entry.snapshot?.doneToday { doneView(d) } else { 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) } } private func doneView(_ d: CoachWidgetSnapshot.DoneActivity) -> some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { Image(systemName: sportSymbol(d.sport)) .foregroundStyle(sportColor(d.sport)) Text(sportLabel(d.sport).uppercased()) .font(.caption2.weight(.bold)).foregroundStyle(.secondary) Spacer() Image(systemName: "checkmark.seal.fill") .foregroundStyle(.green).font(.caption) } Spacer(minLength: 0) // Le sous-titre porte la substance (« 20,3 km · 56 min ») ; le // titre d'une séance importée est souvent vide, d'où le repli. Text(d.subtitle ?? d.title ?? "Séance réalisée") .font(.headline).lineLimit(2).minimumScaleFactor(0.8) if d.subtitle != nil, let t = d.title, !t.isEmpty { Text(t).font(.caption).foregroundStyle(.secondary).lineLimit(1) } } .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 /// Couleur de la bande servie par le serveur. /// /// ⚠️ Les seuils vivaient ici (75 / 50), périmés face à ceux du serveur /// (65 / 55 / 40). Le score étant borné à [25, 75] par construction, le vert /// était inatteignable — 0 jour sur 115 en production. Voir /// `CoachWidgetSnapshot.FormeScore.resolvedBand`, qui porte le repli. private func ringColor(_ band: CoachWidgetSnapshot.FormeBand?) -> Color { switch band { case .great: return Color(red: 0.20, green: 0.78, blue: 0.35) case .good: return Color(red: 0.30, green: 0.85, blue: 0.60) case .medium: return Color(red: 1.00, green: 0.80, blue: 0.0) case .low: return Color(red: 1.00, green: 0.27, blue: 0.23) case nil: return Color.gray } } 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(forme?.resolvedBand), 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]) } } // MARK: - Écran verrouillé (iOS 16+) : familles accessory* // // Les mêmes familles que les complications Apple Watch. Les vues sont // volontairement écrites ici plutôt que partagées avec CoachWatchWidgets : // les deux extensions ne partagent aucun fichier, et l'App Group n'est PAS // commun entre appareils (la montre reçoit son snapshot par WatchConnectivity). // Dupliquer trois petites vues coûte moins qu'un fichier de plus à câbler // dans deux cibles. /// Fond des widgets accessory : transparent, la pile de verrouillage fournit /// le sien. `.fill.tertiary` (celui des widgets d'accueil) y ferait une dalle /// opaque. private extension View { @ViewBuilder func lockWidgetBackground() -> some View { if #available(iOS 17.0, *) { self.containerBackground(.clear, for: .widget) } else { self } } } @available(iOS 16.0, *) struct CoachLockFormeView: View { @Environment(\.widgetFamily) private var family var entry: CoachSnapshotEntry var body: some View { let score = entry.snapshot?.forme?.score let label = entry.snapshot?.forme?.label Group { switch family { case .accessoryInline: // Une ligne, sans mise en forme : le système impose son style. Text(score.map { "Forme \($0)" } ?? "Forme —") case .accessoryRectangular: VStack(alignment: .leading, spacing: 2) { Text("FORME").font(.caption2.weight(.bold)).foregroundStyle(.secondary) Text(score.map(String.init) ?? "—").font(.title2.weight(.semibold)) if let l = label, !l.isEmpty { Text(l).font(.caption2).foregroundStyle(.secondary).lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) default: // .accessoryCircular // Gauge borné 0-100 : le score est un pourcentage de forme, un // dépassement d'échelle donnerait un anneau plein trompeur. Gauge(value: Double(min(100, max(0, score ?? 0))), in: 0...100) { Text("") } currentValueLabel: { Text(score.map(String.init) ?? "—") } .gaugeStyle(.accessoryCircular) } } .lockWidgetBackground() .widgetURL(URL(string: "https://coach.hypnotruck.ch/forme")) } } @available(iOS 16.0, *) struct CoachLockSessionView: View { @Environment(\.widgetFamily) private var family var entry: CoachSnapshotEntry var body: some View { Group { if let s = entry.snapshot?.today { if family == .accessoryInline { Label(s.title, systemImage: sportSymbol(s.sport)) } else { VStack(alignment: .leading, spacing: 2) { Label(sportLabel(s.sport).uppercased(), systemImage: sportSymbol(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) } } .frame(maxWidth: .infinity, alignment: .leading) } } else if let d = entry.snapshot?.doneToday { // Plan vide ≠ journée vide — cf. CoachWidgetSnapshot.DoneActivity. if family == .accessoryInline { Label(d.subtitle ?? sportLabel(d.sport), systemImage: sportSymbol(d.sport)) } else { VStack(alignment: .leading, spacing: 2) { Label(sportLabel(d.sport).uppercased(), systemImage: sportSymbol(d.sport)) .font(.caption2.weight(.bold)).foregroundStyle(.secondary) Text(d.subtitle ?? d.title ?? "Séance réalisée") .font(.headline).lineLimit(1) Text("Réalisée").font(.caption2).foregroundStyle(.green) } .frame(maxWidth: .infinity, alignment: .leading) } } 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) } .frame(maxWidth: .infinity, alignment: .leading) } } } .lockWidgetBackground() .widgetURL(URL(string: "https://coach.hypnotruck.ch/calendar")) } } /// Routine quotidienne : avancement au grain EXERCICE (14/24), le même que la /// page web et l'app Watch. Un grain « bloc » afficherait un autre compte du /// même geste. @available(iOS 16.0, *) struct CoachRoutineView: View { @Environment(\.widgetFamily) private var family var entry: CoachSnapshotEntry var body: some View { let r = entry.snapshot?.routine let text = r.map { "\($0.done)/\($0.total)" } ?? "—" Group { switch family { case .accessoryInline: Label("Routine \(text)", systemImage: "figure.cooldown") case .accessoryRectangular: VStack(alignment: .leading, spacing: 3) { Label("ROUTINE", systemImage: "figure.cooldown") .font(.caption2.weight(.bold)).foregroundStyle(.secondary) Text(text).font(.headline) if let r, r.total > 0 { ProgressView(value: r.fraction).progressViewStyle(.linear) } } .frame(maxWidth: .infinity, alignment: .leading) default: // .accessoryCircular Gauge(value: r?.fraction ?? 0, in: 0...1) { Text("") } currentValueLabel: { // Le total ne tient pas dans l'anneau : on montre le nombre // d'exercices faits, la jauge porte la proportion. Text(r.map { String($0.done) } ?? "—") } .gaugeStyle(.accessoryCircular) } } .lockWidgetBackground() .widgetURL(URL(string: "https://coach.hypnotruck.ch/routine")) } } @available(iOS 16.0, *) struct CoachLockFormeWidget: Widget { let kind = "CoachLockFormeWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in CoachLockFormeView(entry: entry) } .configurationDisplayName("Forme (écran verrouillé)") .description("Ton score de forme, sur l'écran verrouillé.") .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) } } @available(iOS 16.0, *) struct CoachLockSessionWidget: Widget { let kind = "CoachLockSessionWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in CoachLockSessionView(entry: entry) } .configurationDisplayName("Séance (écran verrouillé)") .description("La séance du jour, ou ce que tu as fait.") .supportedFamilies([.accessoryRectangular, .accessoryInline]) } } @available(iOS 16.0, *) struct CoachRoutineWidget: Widget { let kind = "CoachRoutineWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in CoachRoutineView(entry: entry) } .configurationDisplayName("Routine du jour") .description("Exercices de la routine cochés aujourd'hui.") .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) } }