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.
253 lines
11 KiB
Swift
253 lines
11 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?
|
||
}
|
||
|
||
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])
|
||
}
|
||
}
|