Files
coach-ios/ios/App/CoachLiveActivity/CoachQuickWidget.swift
Sylvain Bettinelli 4cff1d4603 Les widgets reçoivent la bande et les préréglages au lieu de les inventer
La couleur du score de forme était décidée ici, avec un barème périmé
(vert >= 75, jaune >= 50). Le serveur avait recalé le sien sur la
distribution réelle (65/55/40) parce que le score est borné à [25, 75] :
une bande haute à 75 est inatteignable. Mesuré sur 115 jours de
production, le vert n'est jamais sorti et le rouge couvrait 68 jours.
La correction n'avait pas atteint le binaire.

`FormeScore` porte désormais la bande servie par le serveur, et
`resolvedBand` ne sert que de repli — aligné sur les seuils serveur, pas
sur les anciens.

Le widget de saisie rapide enregistrait un café à 100 ml, volume qui ne
correspond à aucun préréglage : le serveur avait séparé l'expresso (60)
du mug (250) parce qu'un volume unique faussait le journal, et ces
préréglages sont personnalisables. Les boutons viennent maintenant du
snapshot ; `fallbackQuickDrinks` reprend les préréglages par défaut du
serveur.

15 tests ajoutés au banc d'essai Linux, qui compile réellement ces deux
fichiers : 25 tests verts. Le reste (SwiftUI, WidgetKit) n'est pas
compilable ici et n'a été que relu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:42:19 +00:00

262 lines
12 KiB
Swift
Raw Permalink 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.
// CoachQuickWidget.swift
// Widget « Saisie rapide » : noter un verre d'eau ou un café depuis l'écran
// d'accueil, SANS ouvrir l'application. Photo et code-barres l'ouvrent, eux
// la caméra exige l'app au premier plan, aucun widget ne peut y échapper.
//
// Cible : CoachLiveActivity. Dépend de CoachQuickLog.swift et
// CoachWidgetSnapshot.swift, qui doivent appartenir aux DEUX cibles
// (cf. docs/widgets-runbook-mac.md).
//
// Les boutons d'ajout sont des App Intents (iOS 17+) : ils s'exécutent dans
// l'extension, écrivent dans l'App Group, et rafraîchissent la timeline. Aucun
// appel réseau une extension n'a ni session authentifiée ni droit de tenir
// une requête. L'application transmet la file au serveur à sa prochaine
// ouverture, et le widget affiche en attendant son propre total du jour.
import AppIntents
import SwiftUI
import WidgetKit
// MARK: - Intents
/// Ajoute une boisson sans quitter l'écran d'accueil.
@available(iOS 17.0, watchOS 10.0, *)
struct LogDrinkIntent: AppIntent {
static var title: LocalizedStringResource = "Noter une boisson"
/// `false` : tout l'intérêt est de NE PAS ouvrir l'app.
static var openAppWhenRun: Bool = false
@Parameter(title: "Type") var ref: String
@Parameter(title: "Volume (ml)") var amountMl: Double
init() {}
init(ref: String, amountMl: Double) {
self.ref = ref
self.amountMl = amountMl
}
func perform() async throws -> some IntentResult {
let ok = CoachQuickLog.append(
CoachQuickEntry(kind: .drink, ref: ref, amount: amountMl)
)
WidgetCenter.shared.reloadAllTimelines()
// Un échec d'App Group doit remonter : afficher « ajouté » sur une
// saisie perdue est pire que ne rien afficher.
guard ok else { throw CoachQuickError.unavailable }
return .result()
}
}
enum CoachQuickError: Error, CustomLocalizedStringResourceConvertible {
case unavailable
var localizedStringResource: LocalizedStringResource {
"Stockage partagé indisponible — ouvrez l'app une fois."
}
}
// MARK: - Timeline
struct CoachQuickEntryTimeline: TimelineEntry {
let date: Date
let waterMl: Double
let waterGoal: Double?
let coffeeCount: Int
let pending: Int
let kcal: Double?
let kcalGoal: Double?
/// Boutons de saisie rapide, résolus depuis les préréglages de l'utilisateur.
var quickDrinks: [CoachWidgetSnapshot.QuickDrink] = CoachWidgetSnapshot.fallbackQuickDrinks
}
struct CoachQuickProvider: TimelineProvider {
func placeholder(in context: Context) -> CoachQuickEntryTimeline {
CoachQuickEntryTimeline(
date: Date(), waterMl: 1250, waterGoal: 2750, coffeeCount: 2, pending: 0,
kcal: 1420, kcalGoal: 1950,
quickDrinks: CoachWidgetSnapshot.fallbackQuickDrinks
)
}
func getSnapshot(in context: Context, completion: @escaping (CoachQuickEntryTimeline) -> Void) {
completion(current())
}
/// **Le rendez-vous de minuit existait déjà et ne servait à rien.**
///
/// La version précédente programmait bien `.after(midnight)`, avec le bon
/// commentaire (« pour que les totaux du jour repartent de zéro »). Mais au
/// réveil, `current()` relisait `snapshot.waterMlToday` **sans regarder de
/// quand datait ce snapshot** : elle réaffichait donc exactement les mêmes
/// chiffres. Le rechargement avait lieu, la remise à zéro non.
///
/// C'est `asOf(_:)` qui manquait, pas le réveil.
func getTimeline(in context: Context, completion: @escaping (Timeline<CoachQuickEntryTimeline>) -> Void) {
let now = Date()
let midnight = CoachWidgetSnapshot.nextMidnight(after: now)
completion(Timeline(entries: [current(at: now), current(at: midnight)],
policy: .after(midnight.addingTimeInterval(60))))
}
private func current(at day: Date = Date()) -> CoachQuickEntryTimeline {
// `asOf` vide les totaux du jour quand le snapshot date d'un autre jour.
let snapshot = CoachWidgetStore.load()?.asOf(day)
// Le serveur connaît peut-être déjà des boissons du jour : le snapshot
// les porte. On y ajoute ce qui attend dans la file.
let syncedWater = snapshot?.waterMlToday ?? 0
let syncedCoffee = snapshot?.coffeeCountToday ?? 0
return CoachQuickEntryTimeline(
date: day,
waterMl: CoachQuickLog.todayTotal(kind: "water", syncedMl: syncedWater, day: day),
waterGoal: snapshot?.waterGoalMl,
coffeeCount: CoachQuickLog.todayCount(kind: "coffee", syncedCount: syncedCoffee, day: day),
// `pending` compte ce qui attend d'être envoyé, TOUS jours
// confondus : une saisie d'hier non synchronisée doit encore partir,
// et le badge doit continuer à le signaler.
pending: CoachQuickLog.pending().count,
kcal: snapshot?.kcalToday,
kcalGoal: snapshot?.kcalGoal,
quickDrinks: snapshot?.resolvedQuickDrinks ?? CoachWidgetSnapshot.fallbackQuickDrinks
)
}
}
// MARK: - Vue
@available(iOS 17.0, *)
struct CoachQuickWidgetView: View {
var entry: CoachQuickEntryTimeline
private var quickDrinks: [CoachWidgetSnapshot.QuickDrink] { entry.quickDrinks }
private var litres: String {
String(format: "%.1f", entry.waterMl / 1000).replacingOccurrences(of: ".", with: ",")
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Un widget se lit hors contexte, au milieu d'autres : sans titre,
// « 0,3 L · 2 cafés » ne dit pas de quoi il s'agit ni à quoi servent
// les boutons.
Text("Saisie des repas et boissons")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
/* 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) {
HStack(spacing: 6) {
Text("\(litres) L")
.font(.system(.subheadline, design: .rounded).weight(.semibold))
.monospacedDigit()
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 {
HStack(spacing: 6) {
Text("\(Int(kcal)) kcal")
.font(.system(.subheadline, design: .rounded).weight(.semibold))
.monospacedDigit()
if let goal = entry.kcalGoal, goal > 0 {
ProgressView(value: min(kcal / goal, 1))
.tint(kcal > goal ? .orange : .green)
Text("\(Int(goal))")
.font(.caption2).foregroundStyle(.tertiary).monospacedDigit()
} else {
Spacer()
}
}
}
}
HStack(spacing: 6) {
Text("· \(entry.coffeeCount) café\(entry.coffeeCount > 1 ? "s" : "")")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if entry.pending > 0 {
// Honnêteté du compteur : ce qui n'est pas encore parti au
// serveur est signalé, pas masqué.
Image(systemName: "arrow.triangle.2.circlepath")
.font(.caption2)
.foregroundStyle(.secondary)
.accessibilityLabel("\(entry.pending) saisie(s) en attente d'envoi")
}
}
// Volumes pilotés par les préréglages de l'utilisateur
// (`/api/drinks/presets`), plus écrits ici. Le café valait 100 ml en
// dur, un volume qui ne correspond à aucun préréglage : le serveur
// avait justement séparé l'expresso (60) du mug (250) parce qu'un
// volume unique faussait le journal. Repli documenté dans
// `CoachWidgetSnapshot.fallbackQuickDrinks`.
HStack(spacing: 6) {
ForEach(Array(quickDrinks.enumerated()), id: \.offset) { _, drink in
Button(intent: LogDrinkIntent(ref: drink.ref, amountMl: drink.volumeMl)) {
Label(drink.label,
systemImage: drink.ref == "coffee" ? "cup.and.saucer.fill" : "drop.fill")
}
}
}
.buttonStyle(.bordered)
.font(.caption2)
.labelStyle(.titleAndIcon)
/* Photo et code-barres OUVRENT l'app : la caméra exige le premier
plan. Le widget économise les navigations, pas le lancement.
URL **https** et non schéma custom : le projet route ses liens de
widget par Universal Links (entitlement associated-domains), comme
les widgets Séance du jour et Score de forme. Un `coachapp://`
n'est routé nulle part l'app s'ouvrait alors sur sa dernière
page consultée, au hasard. */
HStack(spacing: 6) {
Link(destination: URL(string: "https://coach.hypnotruck.ch/meals?photo=1")!) {
Label("Photo", systemImage: "camera.fill")
}
Link(destination: URL(string: "https://coach.hypnotruck.ch/meals?scan=1")!) {
Label("Scanner", systemImage: "barcode.viewfinder")
}
}
.buttonStyle(.bordered)
.font(.caption2)
}
.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"))
}
}
@available(iOS 17.0, *)
struct CoachQuickWidget: Widget {
let kind = "CoachQuickWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CoachQuickProvider()) { entry in
CoachQuickWidgetView(entry: entry)
}
.configurationDisplayName("Saisie des repas et boissons")
.description("Noter l'eau et le café sans ouvrir l'app. Photo et code-barres ouvrent le journal.")
.supportedFamilies([.systemMedium])
}
}