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:
113
ios/App/App/CoachQuickLog.swift
Normal file
113
ios/App/App/CoachQuickLog.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,15 @@ struct CoachWidgetSnapshot: Codable {
|
|||||||
var today: TodaySession?
|
var today: TodaySession?
|
||||||
var forme: FormeScore?
|
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).
|
/// Séance planifiée du jour (nil = jour OFF / pas de séance).
|
||||||
struct TodaySession: Codable {
|
struct TodaySession: Codable {
|
||||||
var sport: String // running, cycling, strength, mobility, hiking, rest…
|
var sport: String // running, cycling, strength, mobility, hiking, rest…
|
||||||
|
|||||||
174
ios/App/CoachLiveActivity/CoachQuickWidget.swift
Normal file
174
ios/App/CoachLiveActivity/CoachQuickWidget.swift
Normal file
@@ -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<CoachQuickEntryTimeline>) -> 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user