Files
coach-ios/ios/App/CoachLiveActivity/CoachQuickWidget.swift
Sylvain Bettinelli 9fda989afc 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>
2026-08-06 13:03:16 +00:00

175 lines
6.8 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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])
}
}