Files
coach-ios/ios/App/CoachLiveActivity/CoachQuickWidget.swift
Sylvain Bettinelli b8eb1e1b1e Le widget ne passait pas minuit : personne ne lisait updatedAt
Signale par Sylvain le 2026-08-18 : passe minuit, l'hydratation, les calories
et la seance de la veille restaient affichees comme celles du jour. Il fallait
ouvrir l'app pour les voir retomber a zero.

Le widget n'a AUCUN acces reseau : il rend ce que l'app lui a pousse la
derniere fois qu'elle a tourne. Le snapshot portait deja `updatedAt` — rien
ne le lisait. CoachQuickWidget programmait meme un reveil a minuit, avec le
bon commentaire, mais relisait ensuite les memes chiffres sans regarder leur
date : le rechargement avait lieu, la remise a zero non.

- `CoachWidgetSnapshot.asOf(_:)` : vide ce qui decrit UNE journee (eau, cafes,
  kcal, routine, activite faite, score de forme) quand le snapshot date d'un
  autre jour. Conserve ce qui traverse les jours — objectifs et zones
  cardiaques. Les compteurs repartent a 0 et non a nil : au matin, « rien bu »
  est vrai, « je ne sais pas » ne l'est pas.
- Les trois providers emettent une 2e entree datee de minuit. WidgetKit
  bascule seul, sans reveiller l'app.
- `tomorrow` : la seance du lendemain, poussee par le pont web. Sans elle,
  vider la seance ferait annoncer JOUR OFF chaque nuit — l'agacement d'un
  incident precedent, repete tous les jours.

⚠️ Deuxieme occurrence trouvee en verifiant, et pire : CoachQuickSync.credit()
ajoutait au total EXISTANT puis rehorodatait a maintenant. Sur un snapshot de
la veille, 200 ml bus le matin donnaient « 2 200 ml » estampilles du jour — un
total faux, que la peremption ne pouvait plus rattraper.

⚠️ NON COMPILE : pas de toolchain Swift ici. Build Xcode requis sur le Mac
mini pour que le correctif arrive sur l'iPhone et la Watch.
2026-08-18 07:11:15 +00:00

253 lines
11 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.
// 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?
}
struct CoachQuickProvider: TimelineProvider {
func placeholder(in context: Context) -> CoachQuickEntryTimeline {
CoachQuickEntryTimeline(
date: Date(), waterMl: 1250, waterGoal: 2750, coffeeCount: 2, pending: 0,
kcal: 1420, kcalGoal: 1950
)
}
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
)
}
}
// MARK: - Vue
@available(iOS 17.0, *)
struct CoachQuickWidgetView: View {
var entry: CoachQuickEntryTimeline
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")
}
}
HStack(spacing: 6) {
Button(intent: LogDrinkIntent(ref: "water", amountMl: 250)) {
Label("25 cl", systemImage: "drop.fill")
}
Button(intent: LogDrinkIntent(ref: "water", amountMl: 500)) {
Label("50 cl", systemImage: "drop.fill")
}
Button(intent: LogDrinkIntent(ref: "coffee", amountMl: 100)) {
Label("Café", systemImage: "cup.and.saucer.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])
}
}