diff --git a/ios/App/App/CoachQuickLog.swift b/ios/App/App/CoachQuickLog.swift new file mode 100644 index 0000000..f01ceaa --- /dev/null +++ b/ios/App/App/CoachQuickLog.swift @@ -0,0 +1,113 @@ +// CoachQuickLog.swift +// File d'attente de saisies rapides, PARTAGÉE entre l'app, le widget iPhone et +// la complication Watch, via le même App Group que le snapshot. +// +// ⚠️ Ce fichier doit appartenir à TOUTES les cibles qui le touchent : +// App + CoachLiveActivity + CoachWatch + CoachWatchWidgets +// (cf. docs/widgets-runbook-mac.md, étape « Target Membership »). +// +// Pourquoi une file, et pas un appel réseau direct depuis le widget +// ----------------------------------------------------------------- +// Une extension de widget n'a ni session authentifiée, ni droit de faire +// tourner du réseau plus de quelques instants. Elle écrit donc l'intention +// localement, et l'application la transmet au serveur à sa prochaine ouverture. +// C'est le seul schéma qui tient la promesse « noter un verre d'eau sans ouvrir +// l'app » ET reste honnête : rien n'est déclaré synchronisé tant qu'il ne l'est +// pas. +// +// Conséquence assumée : une saisie faite depuis le widget n'apparaît sur +// coach.hypnotruck.ch qu'après la prochaine ouverture de l'app. Le widget +// affiche donc son propre compteur du jour, alimenté par la file — sinon il +// afficherait un total périmé et l'utilisateur douterait de ses saisies. +import Foundation + +/// Une saisie en attente d'envoi au serveur. +struct CoachQuickEntry: Codable, Identifiable, Equatable { + enum Kind: String, Codable { + case drink // volume en millilitres + case food // aliment favori, identifié par sa référence + } + + let id: String + let kind: Kind + /// « water », « coffee » pour une boisson ; référence d'aliment pour food. + let ref: String + /// Millilitres pour une boisson, grammes pour un aliment. + let amount: Double + /// Horodatage de la SAISIE, pas de l'envoi : c'est l'heure qui compte pour + /// un journal alimentaire, et elle serait perdue en la posant à la synchro. + let at: Date + + init(kind: Kind, ref: String, amount: Double, at: Date = Date()) { + self.id = UUID().uuidString + self.kind = kind + self.ref = ref + self.amount = amount + self.at = at + } +} + +enum CoachQuickLog { + static let queueKey = "coach_quick_queue" + + /// Plafond de la file. Au-delà, c'est que l'app n'a pas été ouverte depuis + /// très longtemps : on garde les plus RÉCENTES, celles qui ont encore une + /// chance d'être justes. + static let maxEntries = 200 + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: CoachWidgetStore.appGroup) + } + + static func pending() -> [CoachQuickEntry] { + guard let data = defaults?.data(forKey: queueKey) else { return [] } + return (try? JSONDecoder().decode([CoachQuickEntry].self, from: data)) ?? [] + } + + /// Ajoute une saisie. Rend `false` si l'App Group est indisponible — le + /// widget doit alors le dire plutôt que d'afficher une confirmation fausse. + @discardableResult + static func append(_ entry: CoachQuickEntry) -> Bool { + guard let d = defaults else { return false } + var queue = pending() + queue.append(entry) + if queue.count > maxEntries { queue.removeFirst(queue.count - maxEntries) } + guard let data = try? JSONEncoder().encode(queue) else { return false } + d.set(data, forKey: queueKey) + return true + } + + /// Retire les saisies transmises. On supprime par identifiant et non en + /// vidant la file : une saisie faite pendant la synchronisation serait + /// sinon perdue sans laisser de trace. + static func remove(ids: Set) { + guard let d = defaults else { return } + let kept = pending().filter { !ids.contains($0.id) } + guard let data = try? JSONEncoder().encode(kept) else { return } + d.set(data, forKey: queueKey) + } + + // MARK: - Totaux du jour + + /// Total d'un type de boisson pour aujourd'hui, file comprise. + /// + /// `synced` est ce que le serveur connaît déjà (poussé par l'app dans le + /// snapshot) ; on y ajoute ce qui attend encore. Sans cela, le widget + /// afficherait un total périmé juste après une saisie, et l'utilisateur + /// douterait de ce qu'il vient de faire. + static func todayTotal(kind ref: String, syncedMl: Double = 0) -> Double { + let calendar = Calendar.current + let waiting = pending() + .filter { $0.kind == .drink && $0.ref == ref && calendar.isDateInToday($0.at) } + .reduce(0) { $0 + $1.amount } + return syncedMl + waiting + } + + static func todayCount(kind ref: String, syncedCount: Int = 0) -> Int { + let calendar = Calendar.current + let waiting = pending() + .filter { $0.kind == .drink && $0.ref == ref && calendar.isDateInToday($0.at) } + .count + return syncedCount + waiting + } +} diff --git a/ios/App/App/CoachWidgetSnapshot.swift b/ios/App/App/CoachWidgetSnapshot.swift index 36e5460..6bc8525 100644 --- a/ios/App/App/CoachWidgetSnapshot.swift +++ b/ios/App/App/CoachWidgetSnapshot.swift @@ -44,6 +44,15 @@ struct CoachWidgetSnapshot: Codable { var today: TodaySession? var forme: FormeScore? + /// Boissons du jour DÉJÀ connues du serveur, en millilitres et en nombre de + /// cafés. Le widget de saisie rapide y ajoute ce qui attend encore dans la + /// file locale : sans ces deux champs, il afficherait « 0,3 L » au lieu de + /// « 1,5 L » tant que l'app n'a pas resynchronisé, et l'utilisateur + /// douterait de saisies pourtant enregistrées. + /// Optionnels : un snapshot écrit par une version antérieure reste lisible. + var waterMlToday: Double? + var coffeeCountToday: Int? + /// Séance planifiée du jour (nil = jour OFF / pas de séance). struct TodaySession: Codable { var sport: String // running, cycling, strength, mobility, hiking, rest… diff --git a/ios/App/CoachLiveActivity/CoachQuickWidget.swift b/ios/App/CoachLiveActivity/CoachQuickWidget.swift new file mode 100644 index 0000000..187fac3 --- /dev/null +++ b/ios/App/CoachLiveActivity/CoachQuickWidget.swift @@ -0,0 +1,174 @@ +// 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]) + } +}