Synchronisation des saisies du widget vers le serveur
`CoachQuickSync.flush()` envoie la file partagée vers POST /api/drinks et retire les entrées transmises. Cible App uniquement : l'extension n'appelle jamais ce code, elle n'a ni session ni droit de tenir une requête — c'est toute la raison d'être de la file. Le cookie de session du WebView est réutilisé plutôt que de refabriquer une authentification qui divergerait. Trois comportements voulus, documentés pour qu'on ne les « corrige » pas : 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é, sans quoi un payload refusé bloquerait la file indéfiniment ; la date est formatée en fuseau local, pour qu'un verre bu à 23 h compte pour le jour où il a été bu. Reste un appel à poser dans AppDelegate.applicationDidBecomeActive — le one-liner est dans le runbook. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
6. Builder sur un device réel — les App Intents ne s'exécutent pas de façon
|
||||||
fiable en simulateur.
|
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
|
`CoachQuickSync.flush()` envoie la file vers `POST /api/drinks` et retire les
|
||||||
de l'app, puis retirée par `CoachQuickLog.remove(ids:)` — **par identifiant, pas
|
entrées transmises **par identifiant**. Reste à l'appeler depuis `AppDelegate` :
|
||||||
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`).
|
|
||||||
|
|
||||||
Tant que ce câblage manque, les saisies restent locales : le widget affiche une
|
```swift
|
||||||
flèche de synchronisation et son compteur reste juste.
|
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.
|
||||||
|
|||||||
90
ios/App/App/CoachQuickSync.swift
Normal file
90
ios/App/App/CoachQuickSync.swift
Normal file
@@ -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<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user