// 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 coffeeCount: Int let pending: Int } struct CoachQuickProvider: TimelineProvider { func placeholder(in context: Context) -> CoachQuickEntryTimeline { CoachQuickEntryTimeline(date: Date(), waterMl: 1250, coffeeCount: 2, pending: 0) } func getSnapshot(in context: Context, completion: @escaping (CoachQuickEntryTimeline) -> Void) { completion(current()) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { // Une seule entrée, rechargée à chaque saisie par `reloadAllTimelines`. // Rafraîchissement de sécurité au prochain minuit, pour que les totaux // du jour repartent de zéro même sans interaction. let midnight = Calendar.current.nextDate( after: Date(), matching: DateComponents(hour: 0, minute: 0), matchingPolicy: .nextTime ) ?? Date().addingTimeInterval(3600) completion(Timeline(entries: [current()], policy: .after(midnight))) } private func current() -> CoachQuickEntryTimeline { let snapshot = CoachWidgetStore.load() // 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: Date(), waterMl: CoachQuickLog.todayTotal(kind: "water", syncedMl: syncedWater), coffeeCount: CoachQuickLog.todayCount(kind: "coffee", syncedCount: syncedCoffee), pending: CoachQuickLog.pending().count ) } } // 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) { HStack(spacing: 6) { Text("\(litres) L") .font(.system(.title3, design: .rounded).weight(.semibold)) .monospacedDigit() 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. HStack(spacing: 6) { Link(destination: URL(string: "coachapp://meals?photo=1")!) { Label("Photo", systemImage: "camera.fill") } Link(destination: URL(string: "coachapp://meals?scan=1")!) { Label("Scanner", systemImage: "barcode.viewfinder") } } .buttonStyle(.bordered) .font(.caption2) } .padding(.vertical, 2) .containerBackground(.fill.tertiary, for: .widget) } } @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 rapide") .description("Noter l'eau et le café sans ouvrir l'app. Photo et code-barres ouvrent le journal.") .supportedFamilies([.systemMedium]) } }