Un widget se lit hors contexte, au milieu d'autres tuiles : sans titre, « 0,3 L · 2 cafés » ne dit ni de quoi il s'agit, ni à quoi servent les boutons. Le nom affiché dans la galerie de widgets est aligné sur ce titre — deux libellés différents pour la même chose obligeraient à les rapprocher de tête. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
190 lines
7.6 KiB
Swift
190 lines
7.6 KiB
Swift
// 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) {
|
||
// Un widget se lit hors contexte, au milieu d'autres : sans titre,
|
||
// « 0,3 L · 2 cafés » ne dit pas de quoi il s'agit ni à quoi servent
|
||
// les boutons.
|
||
Text("Saisie des repas et boissons")
|
||
.font(.caption2.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.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.
|
||
|
||
URL **https** et non schéma custom : le projet route ses liens de
|
||
widget par Universal Links (entitlement associated-domains), comme
|
||
les widgets Séance du jour et Score de forme. Un `coachapp://`
|
||
n'est routé nulle part — l'app s'ouvrait alors sur sa dernière
|
||
page consultée, au hasard. */
|
||
HStack(spacing: 6) {
|
||
Link(destination: URL(string: "https://coach.hypnotruck.ch/meals?photo=1")!) {
|
||
Label("Photo", systemImage: "camera.fill")
|
||
}
|
||
Link(destination: URL(string: "https://coach.hypnotruck.ch/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 des repas et boissons")
|
||
.description("Noter l'eau et le café sans ouvrir l'app. Photo et code-barres ouvrent le journal.")
|
||
.supportedFamilies([.systemMedium])
|
||
}
|
||
}
|