Demandé par Sylvain : des widgets, puis « comment lier Siri à mon appli ». Constat avant de commencer : App Intents déclarés : 1 (LogDrinkIntent) AppShortcutsProvider : 0 -> Siri n'avait AUCUNE phrase à écouter Saisie depuis la montre : 0 -> complications d'affichage seulement Et la cause commune : `LogDrinkIntent` vivait dans `CoachQuickWidget.swift`, qui n'appartient qu'à l'extension widget iPhone. Ni l'app (d'où Siri découvre les raccourcis), ni la cible Watch ne pouvaient le voir. Ce qui est ajouté ------------------ - `CoachIntents.swift` — les intents, partagés entre les TROIS cibles. `LogDrinkIntent` y est déplacé (pas dupliqué : deux définitions du même symbole dans une cible qui compile les deux fichiers casseraient le build), plus `HydrationStatusIntent` et `TodaySessionIntent`. - `CoachAppShortcuts.swift` — 5 raccourcis, 16 phrases françaises. Cible App UNIQUEMENT : Apple attend un seul AppShortcutsProvider par application. - `CoachWatchHydration.swift` — widget Smart Stack de la montre : total du jour, jauge, et deux boutons +cl. Le geste se fait au poignet. Choix qui méritent d'être dits ------------------------------- ⛔ Pas de saisie des repas sur la montre. Pas de caméra, pas de scanner, pas de saisie de texte confortable : le bouton ne ferait que renvoyer vers l'iPhone. Tranché avec Sylvain. ⛔ Pas de volume libre dans les phrases Siri. Un intent paramétré oblige Siri à poser une question — plus long que de sortir le téléphone, et le raccourci perd sa raison d'être. Volumes fixés : 250 ml, 500 ml, café 60 ml. ⛔ Aucune borne en bpm dans les réponses vocales. `TodaySession` n'en porte pas (vérifié : sport, title, subtitle, done) et il ne faut pas en ajouter — une fourchette cardiaque annoncée par haut-parleur, hors de la recommandation du jour et de ses réserves, reviendrait à prescrire une intensité à un post-infarctus. Siri annonce QUOI ; l'app dit COMMENT. Deux boutons et pas trois sur la montre : sur 160 pt, un troisième descend les cibles sous le seuil tactile, pour un geste qu'on fait en marchant. ⚠️ RIEN DE CE CODE N'EST COMPILÉ --------------------------------- AppIntents, WidgetKit et SwiftUI sont absents de Swift pour Linux. Ce qui a été vérifié à la place, par lecture croisée des sources : que chaque symbole appelé existe, et qu'aucun n'est déclaré deux fois. Deux erreurs ont d'ailleurs été trouvées ainsi avant commit — `asOf()` ne rend pas un optionnel, et `TodaySession` n'a pas de `durationMin`. Les erreurs de type, de concurrence et d'API n'apparaîtront qu'au build. `docs/siri-et-widget-watch-runbook-mac.md` porte les étapes Xcode, le tableau de Target Membership, les cinq vérifications à faire sur l'appareil, et les trois questions que seul le build tranchera — dont celle-ci : une complication de cadran accepte-t-elle un bouton interactif, ou est-ce réservé à la Smart Stack ? Par prudence, le cadran n'est ici qu'un affichage. 91 tests Swift Linux au vert (non-régression CoachQuickLog / snapshot). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
235 lines
11 KiB
Swift
235 lines
11 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
|
||
//
|
||
// ⚠️ `LogDrinkIntent` et `CoachQuickError` ont DÉMÉNAGÉ dans
|
||
// `ios/App/App/CoachIntents.swift` le 25/09/2026, pour être partagés entre
|
||
// trois cibles : ce widget, l'app (d'où Siri les découvre) et le widget Watch.
|
||
// Les laisser ici les rendait invisibles aux deux autres — Siri n'avait aucun
|
||
// intent à écouter, et la montre ne savait pas noter un verre.
|
||
// ⛔ Ne pas les redéclarer ici : deux définitions du même symbole dans une
|
||
// cible qui compile les deux fichiers, et le build casse.
|
||
|
||
// MARK: - Timeline
|
||
|
||
struct CoachQuickEntryTimeline: TimelineEntry {
|
||
let date: Date
|
||
let waterMl: Double
|
||
let waterGoal: Double?
|
||
let coffeeCount: Int
|
||
let pending: Int
|
||
let kcal: Double?
|
||
let kcalGoal: Double?
|
||
/// Boutons de saisie rapide, résolus depuis les préréglages de l'utilisateur.
|
||
var quickDrinks: [CoachWidgetSnapshot.QuickDrink] = CoachWidgetSnapshot.fallbackQuickDrinks
|
||
}
|
||
|
||
struct CoachQuickProvider: TimelineProvider {
|
||
func placeholder(in context: Context) -> CoachQuickEntryTimeline {
|
||
CoachQuickEntryTimeline(
|
||
date: Date(), waterMl: 1250, waterGoal: 2750, coffeeCount: 2, pending: 0,
|
||
kcal: 1420, kcalGoal: 1950,
|
||
quickDrinks: CoachWidgetSnapshot.fallbackQuickDrinks
|
||
)
|
||
}
|
||
|
||
func getSnapshot(in context: Context, completion: @escaping (CoachQuickEntryTimeline) -> Void) {
|
||
completion(current())
|
||
}
|
||
|
||
/// ⚠️ **Le rendez-vous de minuit existait déjà — et ne servait à rien.**
|
||
///
|
||
/// La version précédente programmait bien `.after(midnight)`, avec le bon
|
||
/// commentaire (« pour que les totaux du jour repartent de zéro »). Mais au
|
||
/// réveil, `current()` relisait `snapshot.waterMlToday` **sans regarder de
|
||
/// quand datait ce snapshot** : elle réaffichait donc exactement les mêmes
|
||
/// chiffres. Le rechargement avait lieu, la remise à zéro non.
|
||
///
|
||
/// C'est `asOf(_:)` qui manquait, pas le réveil.
|
||
func getTimeline(in context: Context, completion: @escaping (Timeline<CoachQuickEntryTimeline>) -> Void) {
|
||
let now = Date()
|
||
let midnight = CoachWidgetSnapshot.nextMidnight(after: now)
|
||
completion(Timeline(entries: [current(at: now), current(at: midnight)],
|
||
policy: .after(midnight.addingTimeInterval(60))))
|
||
}
|
||
|
||
private func current(at day: Date = Date()) -> CoachQuickEntryTimeline {
|
||
// `asOf` vide les totaux du jour quand le snapshot date d'un autre jour.
|
||
let snapshot = CoachWidgetStore.load()?.asOf(day)
|
||
// 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: day,
|
||
waterMl: CoachQuickLog.todayTotal(kind: "water", syncedMl: syncedWater, day: day),
|
||
waterGoal: snapshot?.waterGoalMl,
|
||
coffeeCount: CoachQuickLog.todayCount(kind: "coffee", syncedCount: syncedCoffee, day: day),
|
||
// ⚠️ `pending` compte ce qui attend d'être envoyé, TOUS jours
|
||
// confondus : une saisie d'hier non synchronisée doit encore partir,
|
||
// et le badge doit continuer à le signaler.
|
||
pending: CoachQuickLog.pending().count,
|
||
kcal: snapshot?.kcalToday,
|
||
kcalGoal: snapshot?.kcalGoal,
|
||
quickDrinks: snapshot?.resolvedQuickDrinks ?? CoachWidgetSnapshot.fallbackQuickDrinks
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - Vue
|
||
|
||
@available(iOS 17.0, *)
|
||
struct CoachQuickWidgetView: View {
|
||
var entry: CoachQuickEntryTimeline
|
||
|
||
private var quickDrinks: [CoachWidgetSnapshot.QuickDrink] { entry.quickDrinks }
|
||
|
||
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)
|
||
|
||
/* Le total reste le chiffre principal ; la jauge n'apparaît que si
|
||
le serveur a envoyé une cible.
|
||
|
||
⚠️ **La cible n'a aucune source** : c'est l'objectif personnel de
|
||
`nutrition_goals.HYDRATION_DEFAULT_ML`, pas une recommandation.
|
||
D'où le libellé « objectif » et non « besoin » ou « recommandé ».
|
||
Sans cible (`nil`), on retombe sur le comportement d'origine — un
|
||
total seul, jamais un pourcentage inventé. */
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(spacing: 6) {
|
||
Text("\(litres) L")
|
||
.font(.system(.subheadline, design: .rounded).weight(.semibold))
|
||
.monospacedDigit()
|
||
if let goal = entry.waterGoal, goal > 0 {
|
||
ProgressView(value: min(entry.waterMl / goal, 1))
|
||
.tint(.cyan)
|
||
Text("objectif \(String(format: "%.1f", goal / 1000).replacingOccurrences(of: ".", with: ",")) L")
|
||
.font(.caption2).foregroundStyle(.tertiary).monospacedDigit()
|
||
} else {
|
||
Text("bue aujourd'hui")
|
||
.font(.caption2).foregroundStyle(.secondary)
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
if let kcal = entry.kcal {
|
||
HStack(spacing: 6) {
|
||
Text("\(Int(kcal)) kcal")
|
||
.font(.system(.subheadline, design: .rounded).weight(.semibold))
|
||
.monospacedDigit()
|
||
if let goal = entry.kcalGoal, goal > 0 {
|
||
ProgressView(value: min(kcal / goal, 1))
|
||
.tint(kcal > goal ? .orange : .green)
|
||
Text("\(Int(goal))")
|
||
.font(.caption2).foregroundStyle(.tertiary).monospacedDigit()
|
||
} else {
|
||
Spacer()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
HStack(spacing: 6) {
|
||
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")
|
||
}
|
||
}
|
||
|
||
// ⚠️ Volumes pilotés par les préréglages de l'utilisateur
|
||
// (`/api/drinks/presets`), plus écrits ici. Le café valait 100 ml en
|
||
// dur, un volume qui ne correspond à aucun préréglage : le serveur
|
||
// avait justement séparé l'expresso (60) du mug (250) parce qu'un
|
||
// volume unique faussait le journal. Repli documenté dans
|
||
// `CoachWidgetSnapshot.fallbackQuickDrinks`.
|
||
HStack(spacing: 6) {
|
||
ForEach(Array(quickDrinks.enumerated()), id: \.offset) { _, drink in
|
||
Button(intent: LogDrinkIntent(ref: drink.ref, amountMl: drink.volumeMl)) {
|
||
Label(drink.label,
|
||
systemImage: drink.ref == "coffee" ? "cup.and.saucer.fill" : "drop.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)
|
||
/* Taper le fond du widget ouvre la page où l'on corrige : /hydratation
|
||
depuis la scission de /meals. Sans `widgetURL`, un tap hors bouton
|
||
ouvrait l'app sur sa dernière page consultée, au hasard.
|
||
https, pas de schéma custom — le projet route par Universal Links. */
|
||
.widgetURL(URL(string: "https://coach.hypnotruck.ch/hydratation"))
|
||
}
|
||
}
|
||
|
||
@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])
|
||
}
|
||
}
|