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>
262 lines
12 KiB
Swift
262 lines
12 KiB
Swift
// 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])
|
||
}
|
||
}
|