// CoachWatchHydration.swift // Widget « Hydratation » pour la Smart Stack de l'Apple Watch : voir où l'on // en est, et **noter un verre sans sortir le téléphone**. // // ⚠️ Cible : CoachWatchWidgets. Dépend de `CoachIntents.swift`, // `CoachQuickLog.swift` et `CoachWidgetSnapshot.swift`, qui doivent appartenir // à CETTE cible aussi (cf. docs/widgets-runbook-mac.md). // // Pourquoi ce widget, et pas un de plus pour les repas // ----------------------------------------------------- // Tranché avec Sylvain le 25/09/2026. Boire se note une vingtaine de fois par // jour : c'est le seul geste où la montre bat vraiment le téléphone — le // poignet est déjà levé. Les repas, non : pas de caméra, pas de scanner de // code-barres, pas de saisie de texte confortable. Un bouton « repas » au // poignet ne ferait que renvoyer vers l'iPhone, pour du code à maintenir. // // ⚠️ **Rien ne part sur le réseau ici.** Comme sur l'iPhone, l'intent écrit // dans la file de `CoachQuickLog` (App Group de la montre) et l'application la // transmet à sa prochaine ouverture. Le widget affiche donc son propre total — // snapshot serveur + file locale — sinon il afficherait un chiffre périmé juste // après une saisie, et on douterait de ce qu'on vient de faire. // // ⚠️ **Interactivité : Smart Stack, pas cadran.** Les boutons d'App Intent // demandent watchOS 10+. Reste à confirmer AU BUILD si une complication de // cadran les accepte : dans le doute, la famille `.accessoryCircular` ci-dessous // n'est qu'un AFFICHAGE (un tap ouvre l'app), et seule `.accessoryRectangular` // porte les boutons. import AppIntents import SwiftUI import WidgetKit // MARK: - Timeline struct CoachWatchHydrationEntry: TimelineEntry { let date: Date let waterMl: Double let goalMl: Double? /// Saisies encore en attente d'envoi, tous jours confondus. let pending: Int let quickDrinks: [CoachWidgetSnapshot.QuickDrink] /// 0…1, borné. Sans cible connue, pas de jauge inventée. var fraction: Double? { guard let goal = goalMl, goal > 0 else { return nil } return min(1, max(0, waterMl / goal)) } } struct CoachWatchHydrationProvider: TimelineProvider { func placeholder(in context: Context) -> CoachWatchHydrationEntry { CoachWatchHydrationEntry( date: Date(), waterMl: 1250, goalMl: 2750, pending: 0, quickDrinks: CoachWidgetSnapshot.fallbackQuickDrinks) } func getSnapshot(in context: Context, completion: @escaping (CoachWatchHydrationEntry) -> Void) { completion(current()) } /// Réveil à minuit, comme le widget iPhone : les totaux du jour doivent /// repartir de zéro. ⚠️ `asOf(_:)` est ce qui le rend effectif — sans lui /// le rechargement a lieu mais réaffiche les chiffres de la veille, défaut /// déjà payé côté iPhone. func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let now = Date() let minuit = CoachWidgetSnapshot.nextMidnight(after: now) completion(Timeline(entries: [current(at: now), current(at: minuit)], policy: .after(minuit.addingTimeInterval(60)))) } private func current(at day: Date = Date()) -> CoachWatchHydrationEntry { let snapshot = CoachWidgetStore.load()?.asOf(day) return CoachWatchHydrationEntry( date: day, waterMl: CoachQuickLog.todayTotal( kind: "water", syncedMl: snapshot?.waterMlToday ?? 0, day: day), goalMl: snapshot?.waterGoalMl, pending: CoachQuickLog.pending().count, quickDrinks: snapshot?.resolvedQuickDrinks ?? CoachWidgetSnapshot.fallbackQuickDrinks) } } // MARK: - Vues /// Les deux premières boissons à base d'eau, sinon le repli. Deux et pas trois : /// sur 160 pt de large, un troisième bouton descend les cibles sous le seuil /// tactile, et c'est un geste qu'on fait en marchant. private func deuxBoutonsEau(_ drinks: [CoachWidgetSnapshot.QuickDrink]) -> [CoachWidgetSnapshot.QuickDrink] { let eau = drinks.filter { $0.ref == "water" } let retenus = eau.isEmpty ? CoachWidgetSnapshot.fallbackQuickDrinks .filter({ $0.ref == "water" }) : eau return Array(retenus.prefix(2)) } private func enLitres(_ ml: Double) -> String { let l = ml / 1000 return String(format: l < 1 ? "%.2f L" : "%.1f L", l) } @available(watchOS 10.0, *) struct CoachWatchHydrationView: View { @Environment(\.widgetFamily) private var family let entry: CoachWatchHydrationEntry var body: some View { switch family { case .accessoryRectangular: rectangulaire case .accessoryInline: Text("Eau \(enLitres(entry.waterMl))") default: circulaire } } /// La seule famille qui porte les boutons : il y faut la place. private var rectangulaire: some View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 4) { Image(systemName: "drop.fill") Text(enLitres(entry.waterMl)) .font(.headline) if let goal = entry.goalMl { Text("/ \(enLitres(goal))") .font(.caption2) .foregroundStyle(.secondary) } // Ce qui attend d'être envoyé se dit, plutôt que de laisser // croire que tout est synchronisé. if entry.pending > 0 { Text("·\(entry.pending)") .font(.caption2) .foregroundStyle(.secondary) } } if let f = entry.fraction { ProgressView(value: f).progressViewStyle(.linear) } HStack(spacing: 6) { ForEach(deuxBoutonsEau(entry.quickDrinks), id: \.volumeMl) { d in Button(intent: LogDrinkIntent(ref: d.ref, amountMl: d.volumeMl)) { Text("+\(Int(d.volumeMl / 10)) cl") .font(.caption2) .frame(maxWidth: .infinity) } .buttonStyle(.bordered) } } } .containerBackground(.clear, for: .widget) } /// Cadran : affichage seul. Un tap ouvre l'app — voir l'avertissement en /// tête de fichier sur l'interactivité hors Smart Stack. private var circulaire: some View { Gauge(value: entry.fraction ?? 0) { Image(systemName: "drop.fill") } currentValueLabel: { Text(enLitres(entry.waterMl)).font(.system(size: 12)) } .gaugeStyle(.accessoryCircular) .containerBackground(.clear, for: .widget) } } // MARK: - Widget @available(watchOS 10.0, *) struct CoachWatchHydrationWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "CoachWatchHydration", provider: CoachWatchHydrationProvider()) { entry in CoachWatchHydrationView(entry: entry) } .configurationDisplayName("Hydratation") .description("Où j'en suis, et noter un verre sans ouvrir l'app.") .supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryInline]) } }