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>
This commit is contained in:
Sylvain Bettinelli
2026-09-25 06:58:11 +00:00
parent 3b5b61268c
commit 98b1cd0c89
6 changed files with 564 additions and 35 deletions

View File

@@ -300,5 +300,10 @@ struct CoachWatchWidgetsBundle: WidgetBundle {
CoachWatchFormeWidget()
CoachWatchSessionWidget()
CoachWatchRoutineWidget()
// Smart Stack : le seul widget de la montre qui fasse AGIR — noter un
// verre sans sortir le téléphone. Ajouté le 25/09/2026.
if #available(watchOS 10.0, *) {
CoachWatchHydrationWidget()
}
}
}

View File

@@ -0,0 +1,183 @@
// CoachWatchHydration.swift
// Widget « Hydratation » pour la Smart Stack de l'Apple Watch : voir où l'on
// en est, et **noter un verre sans sortir le téléphone**.
//
// ⚠️ Cible : CoachWatchWidgets. Dépend de `CoachIntents.swift`,
// `CoachQuickLog.swift` et `CoachWidgetSnapshot.swift`, qui doivent appartenir
// à CETTE cible aussi (cf. docs/widgets-runbook-mac.md).
//
// Pourquoi ce widget, et pas un de plus pour les repas
// -----------------------------------------------------
// Tranché avec Sylvain le 25/09/2026. Boire se note une vingtaine de fois par
// jour : c'est le seul geste où la montre bat vraiment le téléphone — le
// poignet est déjà levé. Les repas, non : pas de caméra, pas de scanner de
// code-barres, pas de saisie de texte confortable. Un bouton « repas » au
// poignet ne ferait que renvoyer vers l'iPhone, pour du code à maintenir.
//
// ⚠️ **Rien ne part sur le réseau ici.** Comme sur l'iPhone, l'intent écrit
// dans la file de `CoachQuickLog` (App Group de la montre) et l'application la
// transmet à sa prochaine ouverture. Le widget affiche donc son propre total —
// snapshot serveur + file locale — sinon il afficherait un chiffre périmé juste
// après une saisie, et on douterait de ce qu'on vient de faire.
//
// ⚠️ **Interactivité : Smart Stack, pas cadran.** Les boutons d'App Intent
// demandent watchOS 10+. Reste à confirmer AU BUILD si une complication de
// cadran les accepte : dans le doute, la famille `.accessoryCircular` ci-dessous
// n'est qu'un AFFICHAGE (un tap ouvre l'app), et seule `.accessoryRectangular`
// porte les boutons.
import AppIntents
import SwiftUI
import WidgetKit
// MARK: - Timeline
struct CoachWatchHydrationEntry: TimelineEntry {
let date: Date
let waterMl: Double
let goalMl: Double?
/// Saisies encore en attente d'envoi, tous jours confondus.
let pending: Int
let quickDrinks: [CoachWidgetSnapshot.QuickDrink]
/// 0…1, borné. Sans cible connue, pas de jauge inventée.
var fraction: Double? {
guard let goal = goalMl, goal > 0 else { return nil }
return min(1, max(0, waterMl / goal))
}
}
struct CoachWatchHydrationProvider: TimelineProvider {
func placeholder(in context: Context) -> CoachWatchHydrationEntry {
CoachWatchHydrationEntry(
date: Date(), waterMl: 1250, goalMl: 2750, pending: 0,
quickDrinks: CoachWidgetSnapshot.fallbackQuickDrinks)
}
func getSnapshot(in context: Context,
completion: @escaping (CoachWatchHydrationEntry) -> Void) {
completion(current())
}
/// Réveil à minuit, comme le widget iPhone : les totaux du jour doivent
/// repartir de zéro. ⚠️ `asOf(_:)` est ce qui le rend effectif — sans lui
/// le rechargement a lieu mais réaffiche les chiffres de la veille, défaut
/// déjà payé côté iPhone.
func getTimeline(in context: Context,
completion: @escaping (Timeline<CoachWatchHydrationEntry>) -> Void) {
let now = Date()
let minuit = CoachWidgetSnapshot.nextMidnight(after: now)
completion(Timeline(entries: [current(at: now), current(at: minuit)],
policy: .after(minuit.addingTimeInterval(60))))
}
private func current(at day: Date = Date()) -> CoachWatchHydrationEntry {
let snapshot = CoachWidgetStore.load()?.asOf(day)
return CoachWatchHydrationEntry(
date: day,
waterMl: CoachQuickLog.todayTotal(
kind: "water", syncedMl: snapshot?.waterMlToday ?? 0, day: day),
goalMl: snapshot?.waterGoalMl,
pending: CoachQuickLog.pending().count,
quickDrinks: snapshot?.resolvedQuickDrinks
?? CoachWidgetSnapshot.fallbackQuickDrinks)
}
}
// MARK: - Vues
/// Les deux premières boissons à base d'eau, sinon le repli. Deux et pas trois :
/// sur 160 pt de large, un troisième bouton descend les cibles sous le seuil
/// tactile, et c'est un geste qu'on fait en marchant.
private func deuxBoutonsEau(_ drinks: [CoachWidgetSnapshot.QuickDrink])
-> [CoachWidgetSnapshot.QuickDrink] {
let eau = drinks.filter { $0.ref == "water" }
let retenus = eau.isEmpty ? CoachWidgetSnapshot.fallbackQuickDrinks
.filter({ $0.ref == "water" }) : eau
return Array(retenus.prefix(2))
}
private func enLitres(_ ml: Double) -> String {
let l = ml / 1000
return String(format: l < 1 ? "%.2f L" : "%.1f L", l)
}
@available(watchOS 10.0, *)
struct CoachWatchHydrationView: View {
@Environment(\.widgetFamily) private var family
let entry: CoachWatchHydrationEntry
var body: some View {
switch family {
case .accessoryRectangular:
rectangulaire
case .accessoryInline:
Text("Eau \(enLitres(entry.waterMl))")
default:
circulaire
}
}
/// La seule famille qui porte les boutons : il y faut la place.
private var rectangulaire: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 4) {
Image(systemName: "drop.fill")
Text(enLitres(entry.waterMl))
.font(.headline)
if let goal = entry.goalMl {
Text("/ \(enLitres(goal))")
.font(.caption2)
.foregroundStyle(.secondary)
}
// Ce qui attend d'être envoyé se dit, plutôt que de laisser
// croire que tout est synchronisé.
if entry.pending > 0 {
Text("·\(entry.pending)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if let f = entry.fraction {
ProgressView(value: f).progressViewStyle(.linear)
}
HStack(spacing: 6) {
ForEach(deuxBoutonsEau(entry.quickDrinks), id: \.volumeMl) { d in
Button(intent: LogDrinkIntent(ref: d.ref, amountMl: d.volumeMl)) {
Text("+\(Int(d.volumeMl / 10)) cl")
.font(.caption2)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
}
.containerBackground(.clear, for: .widget)
}
/// Cadran : affichage seul. Un tap ouvre l'app — voir l'avertissement en
/// tête de fichier sur l'interactivité hors Smart Stack.
private var circulaire: some View {
Gauge(value: entry.fraction ?? 0) {
Image(systemName: "drop.fill")
} currentValueLabel: {
Text(enLitres(entry.waterMl)).font(.system(size: 12))
}
.gaugeStyle(.accessoryCircular)
.containerBackground(.clear, for: .widget)
}
}
// MARK: - Widget
@available(watchOS 10.0, *)
struct CoachWatchHydrationWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "CoachWatchHydration",
provider: CoachWatchHydrationProvider()) { entry in
CoachWatchHydrationView(entry: entry)
}
.configurationDisplayName("Hydratation")
.description("Où j'en suis, et noter un verre sans ouvrir l'app.")
.supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryInline])
}
}