Files
coach-ios/ios/App/CoachLiveActivity/CoachQuickWidget.swift
Sylvain Bettinelli 98b1cd0c89 Siri connaît enfin l'app, et la montre sait noter un verre
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>
2026-09-25 06:58:11 +00:00

235 lines
11 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
//
// ⚠️ `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])
}
}