// 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) -> 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]) } }