diff --git a/docs/widgets-runbook-mac.md b/docs/widgets-runbook-mac.md index 80c7d6e..30f082c 100644 --- a/docs/widgets-runbook-mac.md +++ b/docs/widgets-runbook-mac.md @@ -141,13 +141,21 @@ file, l'app la vide. 6. Builder sur un device réel — les App Intents ne s'exécutent pas de façon fiable en simulateur. -### Ce qui reste à câbler côté app +### Synchronisation (fait — `CoachQuickSync.swift`, cible **App** seule) -La file `CoachQuickLog.pending()` doit être transmise au serveur à l'ouverture -de l'app, puis retirée par `CoachQuickLog.remove(ids:)` — **par identifiant, pas -en vidant la file** : une saisie faite pendant la synchronisation serait sinon -perdue sans laisser de trace. Endpoint : `POST /api/drinks` -(`drink_type`, `volume_ml`, `date`). +`CoachQuickSync.flush()` envoie la file vers `POST /api/drinks` et retire les +entrées transmises **par identifiant**. Reste à l'appeler depuis `AppDelegate` : -Tant que ce câblage manque, les saisies restent locales : le widget affiche une -flèche de synchronisation et son compteur reste juste. +```swift +func applicationDidBecomeActive(_ application: UIApplication) { + Task { await CoachQuickSync.flush() } +} +``` + +Comportements voulus, à ne pas « corriger » : +- une entrée qui échoue **reste en file** et repartira — mieux vaut un doublon + visible qu'une saisie disparue ; +- un **400** est considéré comme traité : le réessayer indéfiniment bloquerait + la file ; +- la date est formatée en **fuseau local** — un verre bu à 23 h compte pour le + jour où il a été bu. diff --git a/ios/App/App/CoachQuickSync.swift b/ios/App/App/CoachQuickSync.swift new file mode 100644 index 0000000..4a31a62 --- /dev/null +++ b/ios/App/App/CoachQuickSync.swift @@ -0,0 +1,90 @@ +// CoachQuickSync.swift +// Transmet au serveur les saisies faites depuis le widget, puis les retire de +// la file partagée. +// +// ⚠️ Cible : App uniquement. L'extension n'appelle jamais ce code — elle n'a ni +// session authentifiée ni droit de tenir une requête réseau ; c'est toute la +// raison d'être de la file. +// +// Quand l'appeler : au démarrage et à chaque retour au premier plan +// (cf. AppDelegate). Une saisie faite sur l'écran d'accueil doit remonter à la +// première ouverture qui suit, sans que l'utilisateur ait à faire quoi que ce +// soit. +import Foundation +import WidgetKit + +enum CoachQuickSync { + /// Le WebView porte le cookie de session : on réutilise le même magasin, + /// plutôt que de refabriquer une authentification qui divergerait. + private static var session: URLSession { + let config = URLSessionConfiguration.default + config.httpCookieStorage = HTTPCookieStorage.shared + config.httpShouldSetCookies = true + config.timeoutIntervalForRequest = 15 + return URLSession(configuration: config) + } + + private static let base = URL(string: "https://coach.hypnotruck.ch")! + + private static let dayFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + // Fuseau LOCAL et non UTC : un verre bu à 23 h doit compter pour le + // jour où il a été bu, pas pour le lendemain. + f.timeZone = TimeZone.current + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + + /// Envoie la file, retire ce qui est passé. Rend le nombre transmis. + /// + /// Les entrées sont retirées **par identifiant** et seulement après un + /// succès : vider la file en bloc perdrait une saisie faite pendant la + /// synchronisation, sans laisser de trace. Une entrée qui échoue reste en + /// file et repartira à la prochaine ouverture — mieux vaut un doublon + /// visible qu'une saisie disparue. + @discardableResult + static func flush() async -> Int { + let queue = CoachQuickLog.pending() + guard !queue.isEmpty else { return 0 } + + var sent = Set() + for entry in queue where entry.kind == .drink { + if await post(entry) { sent.insert(entry.id) } + } + + if !sent.isEmpty { + CoachQuickLog.remove(ids: sent) + // Le widget doit cesser d'afficher la flèche d'attente, et son + // total doit repasser sur la source serveur. + WidgetCenter.shared.reloadAllTimelines() + } + return sent.count + } + + private static func post(_ entry: CoachQuickEntry) async -> Bool { + var request = URLRequest(url: base.appendingPathComponent("api/drinks")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let body: [String: Any] = [ + "drink_type": entry.ref, + "volume_ml": entry.amount, + "date": dayFormatter.string(from: entry.at), + ] + guard let data = try? JSONSerialization.data(withJSONObject: body) else { return false } + request.httpBody = data + + do { + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { return false } + // 400 = payload refusé : le réessayer indéfiniment bloquerait la + // file. On considère l'entrée traitée et on la retire. + if http.statusCode == 400 { return true } + return (200...299).contains(http.statusCode) + } catch { + // Réseau absent : l'entrée reste en file, c'est le cas nominal du + // widget utilisé hors couverture. + return false + } + } +}