From 5bf3e91ad25c12a90547ae669e1b2758d1ab4698 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Thu, 13 Aug 2026 08:33:40 +0000 Subject: [PATCH] Widget Boissons : jauge d'hydratation + ouverture sur /hydratation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le widget affichait un total sans jauge, et c'était une décision documentée et juste : « aucun objectif de boisson n'est fixé, et en inventer un donnerait un pourcentage qui n'a pas de sens ». Une cible existe maintenant côté serveur (`/api/drinks` renvoie `water_target_ml`), donc la jauge peut apparaître — sans cible, on retombe exactement sur l'ancien comportement. ⚠️ Cette cible n'a **aucune source** : c'est un objectif personnel, pas une recommandation médicale. D'où le libellé « objectif » et non « besoin » ou « recommandé ». ⚠️ Les deux côtés du pont bougent ensemble : `widget-bridge.js` pose `waterGoalMl`, `CoachWidgetBridge` la lit sous le même nom. Le widget « JOUR OFF » a menti pendant des jours parce que le pont lisait une clé absente de la réponse serveur. Ajoute aussi un `widgetURL` vers /hydratation : un tap hors bouton ouvrait l'app sur sa dernière page consultée, au hasard. ⚠️ **NON COMPILÉ** — pas de Mac ici. À vérifier au prochain build Xcode, avec les zones cardiaques Watch et `getSnapshot` déjà en attente. Co-Authored-By: Claude Opus 5 --- ios/App/App/CoachWidgetBridge.swift | 5 +++ ios/App/App/CoachWidgetSnapshot.swift | 10 ++++++ .../CoachLiveActivity/CoachQuickWidget.swift | 36 +++++++++++++------ 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/ios/App/App/CoachWidgetBridge.swift b/ios/App/App/CoachWidgetBridge.swift index 002e331..4cf7ef2 100644 --- a/ios/App/App/CoachWidgetBridge.swift +++ b/ios/App/App/CoachWidgetBridge.swift @@ -91,6 +91,11 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { snapshot.routine = routine ?? (Calendar.current.isDateInToday(previous?.updatedAt ?? .distantPast) ? previous?.routine : nil) snapshot.waterMlToday = number("waterMlToday") ?? previous?.waterMlToday + // Même règle de report que les autres champs nutrition : un appel + // partiel ne doit pas effacer la cible, sinon la jauge disparaîtrait au + // premier rafraîchissement incomplet. La clé est posée par + // `widget-bridge.js` — les deux côtés doivent bouger ensemble. + snapshot.waterGoalMl = number("waterGoalMl") ?? previous?.waterGoalMl snapshot.coffeeCountToday = (call.getValue("coffeeCountToday") as? NSNumber)?.intValue ?? previous?.coffeeCountToday snapshot.kcalToday = number("kcalToday") ?? previous?.kcalToday diff --git a/ios/App/App/CoachWidgetSnapshot.swift b/ios/App/App/CoachWidgetSnapshot.swift index f239023..fc3182e 100644 --- a/ios/App/App/CoachWidgetSnapshot.swift +++ b/ios/App/App/CoachWidgetSnapshot.swift @@ -53,6 +53,16 @@ struct CoachWidgetSnapshot: Codable { var waterMlToday: Double? = nil var coffeeCountToday: Int? = nil + /// Objectif d'hydratation du jour, servi par `/api/drinks` depuis la refonte + /// de la page /hydratation (2026-08-13). + /// + /// ⚠️ **Cette cible n'a aucune source** : c'est un objectif personnel, pas + /// une recommandation médicale (voir `nutrition_goals.HYDRATION_DEFAULT_ML` + /// côté serveur). Le widget peut donc l'afficher comme une jauge, jamais + /// comme un seuil de santé. `nil` ⇒ jauge masquée, total seul — c'était le + /// comportement d'origine et il reste le repli. + var waterGoalMl: Double? = nil + /// Énergie du jour et objectif, poussés par l'app depuis le journal. /// `kcalGoal` vient des objectifs nutritionnels réels (1950 au 06/08/2026) : /// il n'est pas écrit en dur ici, pour qu'un changement d'objectif suive. diff --git a/ios/App/CoachLiveActivity/CoachQuickWidget.swift b/ios/App/CoachLiveActivity/CoachQuickWidget.swift index 05fcda2..16d44c4 100644 --- a/ios/App/CoachLiveActivity/CoachQuickWidget.swift +++ b/ios/App/CoachLiveActivity/CoachQuickWidget.swift @@ -58,6 +58,7 @@ enum CoachQuickError: Error, CustomLocalizedStringResourceConvertible { struct CoachQuickEntryTimeline: TimelineEntry { let date: Date let waterMl: Double + let waterGoal: Double? let coffeeCount: Int let pending: Int let kcal: Double? @@ -67,7 +68,7 @@ struct CoachQuickEntryTimeline: TimelineEntry { struct CoachQuickProvider: TimelineProvider { func placeholder(in context: Context) -> CoachQuickEntryTimeline { CoachQuickEntryTimeline( - date: Date(), waterMl: 1250, coffeeCount: 2, pending: 0, + date: Date(), waterMl: 1250, waterGoal: 2750, coffeeCount: 2, pending: 0, kcal: 1420, kcalGoal: 1950 ) } @@ -95,6 +96,7 @@ struct CoachQuickProvider: TimelineProvider { return CoachQuickEntryTimeline( date: Date(), waterMl: CoachQuickLog.todayTotal(kind: "water", syncedMl: syncedWater), + waterGoal: snapshot?.waterGoalMl, coffeeCount: CoachQuickLog.todayCount(kind: "coffee", syncedCount: syncedCoffee), pending: CoachQuickLog.pending().count, kcal: snapshot?.kcalToday, @@ -124,20 +126,29 @@ struct CoachQuickWidgetView: View { .lineLimit(1) .minimumScaleFactor(0.8) - // Hydratation en total, énergie en jauge : seule l'énergie a un - // objectif fixé (1950 kcal). Une barre suppose une cible ; sans - // cible, un chiffre dit plus qu'un pourcentage inventé. + /* Le total reste le chiffre principal ; la jauge n'apparaît que si + le serveur a envoyé une cible. + + ⚠️ **La cible n'a aucune source** : c'est l'objectif personnel de + `nutrition_goals.HYDRATION_DEFAULT_ML`, pas une recommandation. + D'où le libellé « objectif » et non « besoin » ou « recommandé ». + Sans cible (`nil`), on retombe sur le comportement d'origine — un + total seul, jamais un pourcentage inventé. */ VStack(alignment: .leading, spacing: 3) { - // Hydratation : un TOTAL, pas une jauge. Décision assumée — - // aucun objectif de boisson n'est fixé, et en inventer un - // donnerait un pourcentage qui n'a pas de sens. HStack(spacing: 6) { Text("\(litres) L") .font(.system(.subheadline, design: .rounded).weight(.semibold)) .monospacedDigit() - Text("bue aujourd'hui") - .font(.caption2).foregroundStyle(.secondary) - Spacer() + if let goal = entry.waterGoal, goal > 0 { + ProgressView(value: min(entry.waterMl / goal, 1)) + .tint(.cyan) + Text("objectif \(String(format: "%.1f", goal / 1000).replacingOccurrences(of: ".", with: ",")) L") + .font(.caption2).foregroundStyle(.tertiary).monospacedDigit() + } else { + Text("bue aujourd'hui") + .font(.caption2).foregroundStyle(.secondary) + Spacer() + } } if let kcal = entry.kcal { @@ -208,6 +219,11 @@ struct CoachQuickWidgetView: View { } .padding(.vertical, 2) .containerBackground(.fill.tertiary, for: .widget) + /* Taper le fond du widget ouvre la page où l'on corrige : /hydratation + depuis la scission de /meals. Sans `widgetURL`, un tap hors bouton + ouvrait l'app sur sa dernière page consultée, au hasard. + https, pas de schéma custom — le projet route par Universal Links. */ + .widgetURL(URL(string: "https://coach.hypnotruck.ch/hydratation")) } }