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>
328 lines
13 KiB
Swift
328 lines
13 KiB
Swift
// 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])
|
||
}
|
||
}
|