Files
coach-ios/ios/App/CoachWatchWidgets/CoachWatchHydration.swift
Sylvain Bettinelli 6aa7265147 Les macros arrivent au poignet — affichage, pas saisie
Sylvain, après avoir reçu le widget Hydratation : « il manque juste le suivi
des repas calories et lipides etc ».

⚠️ Sa demande est plus fine que ce que j'avais écarté. J'avais dit non à la
SAISIE des repas sur la montre — pas de caméra, pas de scanner, pas de saisie
de texte : un bouton n'aurait fait que renvoyer vers l'iPhone. Mais VOIR où
l'on en est n'a aucune de ces limites.

Ce qui manquait, et où
-----------------------
Le snapshot portait `kcalToday`/`kcalGoal` et rien d'autre. Les macros
existaient pourtant côté serveur, agrégées et servies :

  /api/meals            -> kcal, protein_g, carbs_g, fat_g, fiber_g
  /api/nutrition/goals  -> cibles {target, min, max} pour chacune

Il manquait uniquement le transport. Trois maillons ajoutés : `widget-bridge.js`
les pousse, `CoachWidgetSnapshot` les porte, `CoachWatchNutritionWidget` les
affiche.

⛔ Rien n'est calculé côté natif. Les totaux viennent du journal, les cibles de
`nutrition_goals.py`. Le seul calcul est un reste (cible − consommé), la même
soustraction que /meals affiche déjà.

Le piège évité, parce qu'il est documenté dans ce fichier
---------------------------------------------------------
`asOf(_:)` remet les totaux du jour à zéro quand le snapshot date d'hier. Les
macros y sont ajoutées : les oublier aurait affiché les grammes de la veille
sous la date du jour — le défaut même que cette fonction existe pour empêcher,
et qui avait survécu un temps sur les totaux de boisson.

⚠️ Les CIBLES ne se périment pas, elles : une cible n'est pas une mesure. Sans
ça, la jauge disparaîtrait chaque nuit jusqu'à la prochaine ouverture de l'app.
Deux tests Linux le verrouillent (`testMacrosResetLikeEnergy`,
`testMacroGoalsSurviveTheNight`) — 93 au vert.

⚠️ Une cible de type « min » (les protéines) se lit comme un plancher atteint,
pas comme un plafond à ne pas dépasser. `widget-bridge.js` ne pousse que
`target` ; le widget n'en sait pas plus et ne prétend pas le contraire.

Le widget compile-t-il ? Inconnu, comme les précédents : WidgetKit n'existe pas
sur Linux. Vérifié en revanche que chaque champ appelé existe, et qu'aucun
symbole n'est déclaré deux fois.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 07:46:32 +00:00

328 lines
13 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<CoachWatchHydrationEntry>) -> 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<CoachWatchNutritionEntry>) -> 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])
}
}