Widget natif « Saisie rapide » : eau et café sans ouvrir l'app

Boutons d'App Intent (iOS 17+) pour l'eau et le café : 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.

Photo et code-barres OUVRENT l'application, par `coachapp://meals?photo=1` et
`?scan=1` : la caméra exige le premier plan, aucun widget ne peut y échapper.
Le widget économise les navigations, pas le lancement.

`CoachQuickLog` : file d'attente partagée app ↔ widget ↔ montre. Les saisies
partent au serveur à la prochaine ouverture de l'app, et sont retirées PAR
IDENTIFIANT — vider la file perdrait une saisie faite pendant la
synchronisation, sans laisser de trace.

Le widget affiche son propre total du jour : les boissons déjà connues du
serveur (nouveaux champs `waterMlToday` / `coffeeCountToday` du snapshot, tous
deux optionnels pour rester lisibles depuis une version antérieure) PLUS ce qui
attend dans la file. Sans cela, il afficherait un total périmé juste après une
saisie et l'utilisateur douterait de ce qu'il vient de faire. Ce qui n'est pas
encore parti est signalé par une flèche, pas masqué.

Un échec d'écriture de l'App Group remonte en erreur d'intent : afficher
« ajouté » sur une saisie perdue est pire que ne rien afficher.

Runbook Mac complété — Target Membership, enregistrement dans le WidgetBundle,
schéma d'URL, iOS 17 minimum, et le câblage de synchronisation restant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-06 13:03:16 +00:00
parent f480c36dab
commit 9fda989afc
3 changed files with 296 additions and 0 deletions

View File

@@ -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<String>) {
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
}
}