// CoachWatchComplications.swift // Complications Apple Watch (cible widget extension watchOS « CoachWatchWidgets »). // Lisent le snapshot poussé par l'iPhone via WatchConnectivity et persisté dans // l'App Group de CETTE montre (CoachWidgetStore) — cf. ConnectivityManager.swift // côté CoachWatch + CoachWidgetBridge.pushToWatch côté iPhone. // // ⚠️ Cette cible doit être CRÉÉE dans Xcode (Watch Widget Extension) sur le Mac. // Fichiers à inclure dans sa Target Membership : ce fichier + CoachWidgetSnapshot.swift. // App Group group.ch.hypnotruck.coach à activer sur CoachWatch ET CoachWatchWidgets. // Détails : docs/widgets-runbook-mac.md. import WidgetKit import SwiftUI // MARK: - Helpers locaux (dupliqués : cible séparée des widgets iOS) private func watchSportSymbol(_ 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 watchSportLabel(_ 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 } } // MARK: - Timeline struct WatchEntry: TimelineEntry { let date: Date let snapshot: CoachWidgetSnapshot? } struct WatchProvider: TimelineProvider { func placeholder(in context: Context) -> WatchEntry { WatchEntry(date: Date(), snapshot: nil) } func getSnapshot(in context: Context, completion: @escaping (WatchEntry) -> Void) { let now = Date() completion(WatchEntry(date: now, snapshot: CoachWidgetStore.load()?.asOf(now))) } /// Même correctif que les widgets iPhone : une entrée maintenant, une à /// minuit avec le snapshot périmé remis à zéro. Sans elle, la complication /// affichait les chiffres de la veille jusqu'à réouverture de l'app — /// et sur une montre, c'est encore plus visible : elle est au poignet /// avant que l'iPhone ne soit déverrouillé. func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let now = Date() let stored = CoachWidgetStore.load() let midnight = CoachWidgetSnapshot.nextMidnight(after: now) let entries = [ WatchEntry(date: now, snapshot: stored?.asOf(now)), WatchEntry(date: midnight, snapshot: stored?.asOf(midnight)), ] completion(Timeline(entries: entries, policy: .after(midnight.addingTimeInterval(60)))) } } private extension View { @ViewBuilder func watchWidgetBackground() -> some View { if #available(watchOS 10.0, *) { self.containerBackground(.clear, for: .widget) } else { self } } } // MARK: - Complication « Score de forme » (circular / corner) struct CoachWatchFormeView: View { @Environment(\.widgetFamily) private var family var entry: WatchEntry private var score: Int? { entry.snapshot?.forme?.score } private var band: CoachWidgetSnapshot.FormeBand? { entry.snapshot?.forme?.resolvedBand } /// Couleur de la bande servie par le serveur. /// /// ⚠️ Les seuils vivaient ici (75 / 50) et ne correspondaient plus à ceux du /// serveur (65 / 55 / 40). Le score étant borné à [25, 75], le vert était /// inatteignable : sur 115 jours de production, il n'est jamais sorti, et le /// rouge couvrait 68 jours. La bande arrive maintenant avec le snapshot ; /// le repli vit dans `FormeScore.resolvedBand`, aligné sur le serveur. private func tint(_ band: CoachWidgetSnapshot.FormeBand?) -> Color { switch band { case .great: return .green case .good: return .mint case .medium: return .yellow case .low: return .red case nil: return .gray } } var body: some View { Group { if let s = score { switch family { case .accessoryCorner: Text("\(s)") .font(.title3.weight(.semibold)) .widgetCurvesContent(label: "Forme") default: // accessoryCircular Gauge(value: Double(s), in: 0...100) { Image(systemName: "bolt.heart.fill") } currentValueLabel: { Text("\(s)") } .gaugeStyle(.accessoryCircular) .tint(tint(band)) } } else { Image(systemName: "bolt.heart") .font(.headline) .foregroundStyle(.secondary) } } .watchWidgetBackground() .widgetURL(URL(string: "https://coach.hypnotruck.ch/forme")) } } private extension View { // .widgetLabel pour la famille .accessoryCorner (texte courbé autour du cadran). @ViewBuilder func widgetCurvesContent(label: String) -> some View { self.widgetLabel(label) } } struct CoachWatchFormeWidget: Widget { let kind = "CoachWatchFormeWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in CoachWatchFormeView(entry: entry) } .configurationDisplayName("Forme") .description("Ton score de forme du jour.") .supportedFamilies([.accessoryCircular, .accessoryCorner]) } } // MARK: - Complication « Séance du jour » (rectangular / inline) struct CoachWatchSessionView: View { @Environment(\.widgetFamily) private var family var entry: WatchEntry var body: some View { let today = entry.snapshot?.today Group { if let s = today { if family == .accessoryCircular { // Le cadran ne tient pas un titre : l'icône du sport porte // l'information, la pastille verte dit qu'elle est faite. ZStack { AccessoryWidgetBackground() Image(systemName: watchSportSymbol(s.sport)) .font(.title3) .foregroundStyle(s.done ? .green : .primary) } } else if family == .accessoryInline { Label(s.title, systemImage: watchSportSymbol(s.sport)) } else { // accessoryRectangular VStack(alignment: .leading, spacing: 2) { Label(watchSportLabel(s.sport).uppercased(), systemImage: watchSportSymbol(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) } } } } else if let d = entry.snapshot?.doneToday { /* Rien au plan, mais quelque chose a été fait : le plan se vide quand l'activité est remplacée (« j'ai fait du VTT »). Afficher « Jour OFF » ce jour-là est faux au poignet de quelqu'un qui vient de rentrer de sortie. */ if family == .accessoryInline { Label(d.subtitle ?? watchSportLabel(d.sport), systemImage: watchSportSymbol(d.sport)) } else { VStack(alignment: .leading, spacing: 2) { Label(watchSportLabel(d.sport).uppercased(), systemImage: watchSportSymbol(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) } } } 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) } } } } .watchWidgetBackground() .widgetURL(URL(string: "https://coach.hypnotruck.ch/calendar")) } } struct CoachWatchSessionWidget: Widget { let kind = "CoachWatchSessionWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in CoachWatchSessionView(entry: entry) } .configurationDisplayName("Séance du jour") .description("La séance prévue aujourd'hui.") .supportedFamilies([.accessoryRectangular, .accessoryInline, .accessoryCircular]) } } // MARK: - Complication « Routine du jour » (circular / rectangular / inline) /// Avancement au grain EXERCICE (14/24) — le même que la page web et l'app /// Watch depuis le 2026-08-03. Un grain « bloc » donnerait un second compte /// du même geste, et les deux finiraient par diverger. struct CoachWatchRoutineView: View { @Environment(\.widgetFamily) private var family var entry: WatchEntry 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: 2) { 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) } } default: // accessoryCircular Gauge(value: r?.fraction ?? 0, in: 0...1) { Image(systemName: "figure.cooldown") } currentValueLabel: { Text(r.map { String($0.done) } ?? "—") } .gaugeStyle(.accessoryCircular) .tint(.mint) } } .watchWidgetBackground() .widgetURL(URL(string: "https://coach.hypnotruck.ch/routine")) } } struct CoachWatchRoutineWidget: Widget { let kind = "CoachWatchRoutineWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in CoachWatchRoutineView(entry: entry) } .configurationDisplayName("Routine") .description("Exercices de la routine cochés aujourd'hui.") .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) } } // MARK: - Bundle @main de l'extension @main struct CoachWatchWidgetsBundle: WidgetBundle { var body: some Widget { CoachWatchFormeWidget() CoachWatchSessionWidget() CoachWatchRoutineWidget() } }