// 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]) } } // MARK: - Nutrition // // ⚠️ **Affichage seulement, et c'est délibéré.** La saisie des repas au poignet // a été écartée le 25/09 : pas de caméra, pas de scanner de code-barres, pas de // saisie de texte confortable — un bouton n'aurait fait que renvoyer vers // l'iPhone. Mais VOIR où l'on en est n'a aucune de ces limites, et c'est ce que // Sylvain a demandé ensuite. // // ⛔ Rien n'est calculé ici. Les totaux viennent du journal et les cibles de // `nutrition_goals.py`, tous deux poussés par l'app. Le seul calcul est un // reste (cible − consommé), la même soustraction que la page /meals affiche. struct CoachWatchNutritionEntry: TimelineEntry { let date: Date let kcal: Double? let kcalGoal: Double? let protein: Double? let proteinGoal: Double? let fat: Double? let fatGoal: Double? let carbs: Double? let carbsGoal: Double? /// Reste d'énergie. `nil` sans cible — une jauge sans objectif n'a pas de /// sens, et un « reste » inventé encore moins. var kcalRestantes: Double? { guard let g = kcalGoal, let c = kcal else { return nil } return max(0, g - c) } var fraction: Double? { guard let g = kcalGoal, g > 0, let c = kcal else { return nil } return min(1, max(0, c / g)) } } struct CoachWatchNutritionProvider: TimelineProvider { func placeholder(in context: Context) -> CoachWatchNutritionEntry { CoachWatchNutritionEntry(date: Date(), kcal: 1186, kcalGoal: 1908, protein: 74, proteinGoal: 118, fat: 41, fatGoal: 59, carbs: 158, carbsGoal: 225) } func getSnapshot(in context: Context, completion: @escaping (CoachWatchNutritionEntry) -> Void) { completion(current()) } 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)))) } /// `asOf(_:)` remet les totaux du jour à zéro quand le snapshot date /// d'hier — macros comprises depuis le 25/09. Sans lui, le réveil de /// minuit réafficherait les grammes de la veille. private func current(at day: Date = Date()) -> CoachWatchNutritionEntry { let s = CoachWidgetStore.load()?.asOf(day) return CoachWatchNutritionEntry( date: day, kcal: s?.kcalToday, kcalGoal: s?.kcalGoal, protein: s?.proteinToday, proteinGoal: s?.proteinGoal, fat: s?.fatToday, fatGoal: s?.fatGoal, carbs: s?.carbsToday, carbsGoal: s?.carbsGoal) } } @available(watchOS 10.0, *) struct CoachWatchNutritionView: View { @Environment(\.widgetFamily) private var family let entry: CoachWatchNutritionEntry /// « 74/118 g » ; « 74 g » sans cible. Jamais de cible inventée. private func macro(_ nom: String, _ valeur: Double?, _ cible: Double?) -> String { guard let v = valeur else { return "\(nom) —" } if let c = cible { return "\(nom) \(Int(v))/\(Int(c))" } return "\(nom) \(Int(v))" } var body: some View { switch family { case .accessoryRectangular: rectangulaire case .accessoryInline: Text(entry.kcalRestantes.map { "\(Int($0)) kcal restantes" } ?? "\(Int(entry.kcal ?? 0)) kcal") default: circulaire } } private var rectangulaire: some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Image(systemName: "fork.knife") if let reste = entry.kcalRestantes { Text("\(Int(reste))").font(.headline) Text("kcal restantes").font(.caption2).foregroundStyle(.secondary) } else { Text("\(Int(entry.kcal ?? 0)) kcal").font(.headline) } } if let f = entry.fraction { ProgressView(value: f).progressViewStyle(.linear) } // P / L / G sur une ligne : trois valeurs tiennent, pas davantage. Text([macro("P", entry.protein, entry.proteinGoal), macro("L", entry.fat, entry.fatGoal), macro("G", entry.carbs, entry.carbsGoal)].joined(separator: " ")) .font(.system(size: 11)) .foregroundStyle(.secondary) } .containerBackground(.clear, for: .widget) } private var circulaire: some View { Gauge(value: entry.fraction ?? 0) { Image(systemName: "fork.knife") } currentValueLabel: { Text("\(Int(entry.kcalRestantes ?? entry.kcal ?? 0))") .font(.system(size: 12)) } .gaugeStyle(.accessoryCircular) .containerBackground(.clear, for: .widget) } } @available(watchOS 10.0, *) struct CoachWatchNutritionWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "CoachWatchNutrition", provider: CoachWatchNutritionProvider()) { entry in CoachWatchNutritionView(entry: entry) } .configurationDisplayName("Nutrition") .description("Calories restantes et macros du jour.") .supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryInline]) } }