Files
coach-ios/ios/App/CoachWatchWidgets/CoachWatchComplications.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

311 lines
12 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.
// CoachWatchComplications.swift
// Complications Apple Watch (cible widget extension watchOS « CoachWatchWidgets »).
// Lisent le snapshot poussé par l'iPhone via WatchConnectivity et persisté dans
// l'App Group de CETTE montre (CoachWidgetStore) — cf. ConnectivityManager.swift
// côté CoachWatch + CoachWidgetBridge.pushToWatch côté iPhone.
//
// ⚠️ Cette cible doit être CRÉÉE dans Xcode (Watch Widget Extension) sur le Mac.
// Fichiers à inclure dans sa Target Membership : ce fichier + CoachWidgetSnapshot.swift.
// App Group group.ch.hypnotruck.coach à activer sur CoachWatch ET CoachWatchWidgets.
// Détails : docs/widgets-runbook-mac.md.
import WidgetKit
import SwiftUI
// MARK: - Helpers locaux (dupliqués : cible séparée des widgets iOS)
private func watchSportSymbol(_ sport: String) -> String {
switch sport {
case "cycling": return "bicycle"
case "walking": return "figure.walk"
case "hiking": return "figure.hiking"
case "strength": return "dumbbell.fill"
case "mobility": return "figure.yoga"
case "rest": return "moon.zzz.fill"
default: return "figure.run"
}
}
private func watchSportLabel(_ sport: String) -> String {
switch sport {
case "running": return "Course"
case "cycling": return "Vélo"
case "walking": return "Marche"
case "hiking": return "Rando"
case "strength": return "Renfo"
case "mobility": return "Mobilité"
case "rest": return "Repos"
default: return sport.capitalized
}
}
// MARK: - Timeline
struct WatchEntry: TimelineEntry {
let date: Date
let snapshot: CoachWidgetSnapshot?
}
struct WatchProvider: TimelineProvider {
func placeholder(in context: Context) -> WatchEntry {
WatchEntry(date: Date(), snapshot: nil)
}
func getSnapshot(in context: Context, completion: @escaping (WatchEntry) -> Void) {
let now = Date()
completion(WatchEntry(date: now, snapshot: CoachWidgetStore.load()?.asOf(now)))
}
/// Même correctif que les widgets iPhone : une entrée maintenant, une à
/// minuit avec le snapshot périmé remis à zéro. Sans elle, la complication
/// affichait les chiffres de la veille jusqu'à réouverture de l'app —
/// et sur une montre, c'est encore plus visible : elle est au poignet
/// avant que l'iPhone ne soit déverrouillé.
func getTimeline(in context: Context, completion: @escaping (Timeline<WatchEntry>) -> Void) {
let now = Date()
let stored = CoachWidgetStore.load()
let midnight = CoachWidgetSnapshot.nextMidnight(after: now)
let entries = [
WatchEntry(date: now, snapshot: stored?.asOf(now)),
WatchEntry(date: midnight, snapshot: stored?.asOf(midnight)),
]
completion(Timeline(entries: entries,
policy: .after(midnight.addingTimeInterval(60))))
}
}
private extension View {
@ViewBuilder
func watchWidgetBackground() -> some View {
if #available(watchOS 10.0, *) {
self.containerBackground(.clear, for: .widget)
} else {
self
}
}
}
// MARK: - Complication « Score de forme » (circular / corner)
struct CoachWatchFormeView: View {
@Environment(\.widgetFamily) private var family
var entry: WatchEntry
private var score: Int? { entry.snapshot?.forme?.score }
private var band: CoachWidgetSnapshot.FormeBand? { entry.snapshot?.forme?.resolvedBand }
/// Couleur de la bande servie par le serveur.
///
/// ⚠️ Les seuils vivaient ici (75 / 50) et ne correspondaient plus à ceux du
/// serveur (65 / 55 / 40). Le score étant borné à [25, 75], le vert était
/// inatteignable : sur 115 jours de production, il n'est jamais sorti, et le
/// rouge couvrait 68 jours. La bande arrive maintenant avec le snapshot ;
/// le repli vit dans `FormeScore.resolvedBand`, aligné sur le serveur.
private func tint(_ band: CoachWidgetSnapshot.FormeBand?) -> Color {
switch band {
case .great: return .green
case .good: return .mint
case .medium: return .yellow
case .low: return .red
case nil: return .gray
}
}
var body: some View {
Group {
if let s = score {
switch family {
case .accessoryCorner:
Text("\(s)")
.font(.title3.weight(.semibold))
.widgetCurvesContent(label: "Forme")
default: // accessoryCircular
Gauge(value: Double(s), in: 0...100) {
Image(systemName: "bolt.heart.fill")
} currentValueLabel: {
Text("\(s)")
}
.gaugeStyle(.accessoryCircular)
.tint(tint(band))
}
} else {
Image(systemName: "bolt.heart")
.font(.headline)
.foregroundStyle(.secondary)
}
}
.watchWidgetBackground()
.widgetURL(URL(string: "https://coach.hypnotruck.ch/forme"))
}
}
private extension View {
// .widgetLabel pour la famille .accessoryCorner (texte courbé autour du cadran).
@ViewBuilder
func widgetCurvesContent(label: String) -> some View {
self.widgetLabel(label)
}
}
struct CoachWatchFormeWidget: Widget {
let kind = "CoachWatchFormeWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in
CoachWatchFormeView(entry: entry)
}
.configurationDisplayName("Forme")
.description("Ton score de forme du jour.")
.supportedFamilies([.accessoryCircular, .accessoryCorner])
}
}
// MARK: - Complication « Séance du jour » (rectangular / inline)
struct CoachWatchSessionView: View {
@Environment(\.widgetFamily) private var family
var entry: WatchEntry
var body: some View {
let today = entry.snapshot?.today
Group {
if let s = today {
if family == .accessoryCircular {
// Le cadran ne tient pas un titre : l'icône du sport porte
// l'information, la pastille verte dit qu'elle est faite.
ZStack {
AccessoryWidgetBackground()
Image(systemName: watchSportSymbol(s.sport))
.font(.title3)
.foregroundStyle(s.done ? .green : .primary)
}
} else if family == .accessoryInline {
Label(s.title, systemImage: watchSportSymbol(s.sport))
} else { // accessoryRectangular
VStack(alignment: .leading, spacing: 2) {
Label(watchSportLabel(s.sport).uppercased(),
systemImage: watchSportSymbol(s.sport))
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
Text(s.title).font(.headline).lineLimit(1)
if let sub = s.subtitle, !sub.isEmpty {
Text(sub).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
} else if s.done {
Text("Réalisée").font(.caption2).foregroundStyle(.green)
}
}
}
} else if let d = entry.snapshot?.doneToday {
/* Rien au plan, mais quelque chose a été fait : le plan se vide
quand l'activité est remplacée (« j'ai fait du VTT »).
Afficher « Jour OFF » ce jour-là est faux au poignet de
quelqu'un qui vient de rentrer de sortie. */
if family == .accessoryInline {
Label(d.subtitle ?? watchSportLabel(d.sport),
systemImage: watchSportSymbol(d.sport))
} else {
VStack(alignment: .leading, spacing: 2) {
Label(watchSportLabel(d.sport).uppercased(),
systemImage: watchSportSymbol(d.sport))
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
Text(d.subtitle ?? d.title ?? "Séance réalisée")
.font(.headline).lineLimit(1)
Text("Réalisée").font(.caption2).foregroundStyle(.green)
}
}
} else {
if family == .accessoryInline {
Label("Jour OFF", systemImage: "moon.zzz.fill")
} else {
VStack(alignment: .leading, spacing: 2) {
Label("JOUR OFF", systemImage: "moon.zzz.fill")
.font(.caption2.weight(.bold)).foregroundStyle(.secondary)
Text("Pas de séance").font(.footnote)
}
}
}
}
.watchWidgetBackground()
.widgetURL(URL(string: "https://coach.hypnotruck.ch/calendar"))
}
}
struct CoachWatchSessionWidget: Widget {
let kind = "CoachWatchSessionWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in
CoachWatchSessionView(entry: entry)
}
.configurationDisplayName("Séance du jour")
.description("La séance prévue aujourd'hui.")
.supportedFamilies([.accessoryRectangular, .accessoryInline, .accessoryCircular])
}
}
// MARK: - Complication « Routine du jour » (circular / rectangular / inline)
/// Avancement au grain EXERCICE (14/24) — le même que la page web et l'app
/// Watch depuis le 2026-08-03. Un grain « bloc » donnerait un second compte
/// du même geste, et les deux finiraient par diverger.
struct CoachWatchRoutineView: View {
@Environment(\.widgetFamily) private var family
var entry: WatchEntry
var body: some View {
let r = entry.snapshot?.routine
let text = r.map { "\($0.done)/\($0.total)" } ?? "—"
Group {
switch family {
case .accessoryInline:
Label("Routine \(text)", systemImage: "figure.cooldown")
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 2) {
Label("ROUTINE", systemImage: "figure.cooldown")
.font(.caption2.weight(.bold)).foregroundStyle(.secondary)
Text(text).font(.headline)
if let r, r.total > 0 {
ProgressView(value: r.fraction).progressViewStyle(.linear)
}
}
default: // accessoryCircular
Gauge(value: r?.fraction ?? 0, in: 0...1) {
Image(systemName: "figure.cooldown")
} currentValueLabel: {
Text(r.map { String($0.done) } ?? "—")
}
.gaugeStyle(.accessoryCircular)
.tint(.mint)
}
}
.watchWidgetBackground()
.widgetURL(URL(string: "https://coach.hypnotruck.ch/routine"))
}
}
struct CoachWatchRoutineWidget: Widget {
let kind = "CoachWatchRoutineWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in
CoachWatchRoutineView(entry: entry)
}
.configurationDisplayName("Routine")
.description("Exercices de la routine cochés aujourd'hui.")
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
// MARK: - Bundle @main de l'extension
@main
struct CoachWatchWidgetsBundle: WidgetBundle {
var body: some Widget {
CoachWatchFormeWidget()
CoachWatchSessionWidget()
CoachWatchRoutineWidget()
// Smart Stack : le seul widget de la montre qui fasse AGIR — noter un
// verre sans sortir le téléphone. Ajouté le 25/09/2026.
if #available(watchOS 10.0, *) {
CoachWatchHydrationWidget()
CoachWatchNutritionWidget()
}
}
}