// 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]) } }