// 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) { completion(WatchEntry(date: Date(), snapshot: CoachWidgetStore.load())) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let entry = WatchEntry(date: Date(), snapshot: CoachWidgetStore.load()) let next = Calendar.current.date(byAdding: .hour, value: 2, to: Date()) ?? Date().addingTimeInterval(7200) completion(Timeline(entries: [entry], policy: .after(next))) } } 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 func tint(_ s: Int) -> Color { switch s { case 75...: return .green case 50..<75: return .yellow default: return .red } } 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(s)) } } 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 == .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 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]) } } // MARK: - Bundle @main de l'extension @main struct CoachWatchWidgetsBundle: WidgetBundle { var body: some Widget { CoachWatchFormeWidget() CoachWatchSessionWidget() } }