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

@@ -0,0 +1,89 @@
// CoachAppShortcuts.swift
// Les phrases que Siri écoute. C'est ce qui manquait pour « lier Siri à
// l'app » : les App Intents existaient (`CoachIntents.swift`), mais sans
// `AppShortcutsProvider` aucune phrase ne les déclenche — Siri ne sait pas
// quoi écouter, et les actions ne remontent que dans l'app Raccourcis.
//
// ⚠️ **Cible App UNIQUEMENT**, pas les extensions.
// Apple attend un seul `AppShortcutsProvider` par application ; en déclarer un
// second dans une extension widget produirait au mieux un doublon.
// Les INTENTS, eux, sont partagés (cf. `CoachIntents.swift`).
//
// ⚠️ **Chaque phrase doit contenir `\(.applicationName)`** — Apple le refuse
// autrement, et le raccourci n'apparaît simplement pas. Le nom vient de
// `CFBundleDisplayName` ; les variantes ci-dessous supposent « Coach ».
//
// ⚠️ **Dix phrases maximum par intent**, et les premières comptent le plus :
// ce sont celles que Siri propose spontanément.
import AppIntents
@available(iOS 17.0, watchOS 10.0, *)
struct CoachAppShortcuts: AppShortcutsProvider {
/// Couleur de la tuile dans l'app Raccourcis.
static var shortcutTileColor: ShortcutTileColor = .teal
static var appShortcuts: [AppShortcut] {
// ── Noter un verre d'eau ────────────────────────────────────────
// Le geste à une vingtaine de fois par jour : c'est lui qui justifie
// Siri. Les paramètres sont FIXÉS ici (250 ml) — un intent avec
// paramètre libre oblige Siri à poser une question, ce qui coûte plus
// de temps que de sortir le téléphone.
AppShortcut(
intent: LogDrinkIntent(ref: "water", amountMl: 250),
phrases: [
"Note un verre d'eau dans \(.applicationName)",
"J'ai bu un verre d'eau dans \(.applicationName)",
"Ajoute un verre d'eau dans \(.applicationName)",
"Un verre d'eau dans \(.applicationName)",
],
shortTitle: "Un verre d'eau",
systemImageName: "drop.fill"
)
AppShortcut(
intent: LogDrinkIntent(ref: "water", amountMl: 500),
phrases: [
"Note une bouteille d'eau dans \(.applicationName)",
"J'ai bu une bouteille dans \(.applicationName)",
"Ajoute une grande eau dans \(.applicationName)",
],
shortTitle: "Une bouteille d'eau",
systemImageName: "waterbottle.fill"
)
AppShortcut(
intent: LogDrinkIntent(ref: "coffee", amountMl: 60),
phrases: [
"Note un café dans \(.applicationName)",
"J'ai bu un café dans \(.applicationName)",
"Ajoute un café dans \(.applicationName)",
],
shortTitle: "Un café",
systemImageName: "cup.and.saucer.fill"
)
// ── Consulter ───────────────────────────────────────────────────
AppShortcut(
intent: HydrationStatusIntent(),
phrases: [
"Combien j'ai bu dans \(.applicationName)",
"Où j'en suis côté hydratation dans \(.applicationName)",
"Mon hydratation dans \(.applicationName)",
],
shortTitle: "Mon hydratation",
systemImageName: "drop.circle"
)
AppShortcut(
intent: TodaySessionIntent(),
phrases: [
"Quelle est ma séance du jour dans \(.applicationName)",
"Ma séance dans \(.applicationName)",
"Qu'est-ce que je fais aujourd'hui dans \(.applicationName)",
],
shortTitle: "Ma séance du jour",
systemImageName: "figure.run"
)
}
}

View File

@@ -0,0 +1,178 @@
// CoachIntents.swift
// App Intents partagés : app iPhone, widget iPhone, widget Watch, Siri.
//
// ⚠️ **Ce fichier doit appartenir à TOUTES les cibles qui s'en servent** :
// App + CoachLiveActivity + CoachWatchWidgets
// (cf. docs/widgets-runbook-mac.md, étape « Target Membership »).
//
// Pourquoi il existe
// ------------------
// `LogDrinkIntent` vivait dans `CoachQuickWidget.swift`, qui n'appartient qu'à
// l'extension widget iPhone. Deux conséquences, constatées le 25/09/2026 :
//
// - **Siri ne pouvait pas y accéder.** Les raccourcis (`AppShortcutsProvider`)
// se déclarent dans l'application principale, qui ne voyait pas cet intent.
// - **La montre non plus.** La cible `CoachWatchWidgets` ne portait que des
// complications d'affichage : aucune saisie n'était possible au poignet,
// alors que c'est le geste que Sylvain fait une vingtaine de fois par jour.
//
// L'extraire ici, plutôt que le dupliquer, évite deux implémentations qui
// divergeraient — le défaut déjà payé sur l'envoi vers la Watch.
//
// ⛔ Aucun appel réseau ici, et c'est structurel : une extension n'a ni session
// authentifiée ni droit de tenir une requête. L'intent écrit dans la file de
// `CoachQuickLog` (App Group), et l'application la transmet au serveur à sa
// prochaine ouverture. Rien n'est déclaré synchronisé tant qu'il ne l'est pas.
import AppIntents
import Foundation
#if canImport(WidgetKit)
import WidgetKit
#endif
// MARK: - Erreurs
enum CoachQuickError: Error, CustomLocalizedStringResourceConvertible {
case unavailable
var localizedStringResource: LocalizedStringResource {
"Stockage partagé indisponible — ouvrez l'app une fois."
}
}
// MARK: - Noter une boisson
/// Ajoute une boisson sans quitter l'écran d'accueil, le cadran, ou en dictant
/// à Siri.
@available(iOS 17.0, watchOS 10.0, *)
struct LogDrinkIntent: AppIntent {
static var title: LocalizedStringResource = "Noter une boisson"
static var description = IntentDescription(
"Ajoute un verre d'eau ou un café au journal, sans ouvrir l'application."
)
/// `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 & ProvidesDialog {
let ok = CoachQuickLog.append(
CoachQuickEntry(kind: .drink, ref: ref, amount: amountMl)
)
#if canImport(WidgetKit)
WidgetCenter.shared.reloadAllTimelines()
#endif
// 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 }
// ⚠️ Le dialogue annonce ce qui a été NOTÉ, pas ce qui a été envoyé au
// serveur — la file part à la prochaine ouverture de l'app. Dire
// « enregistré sur ton compte » serait faux pendant plusieurs heures.
let total = CoachQuickLog.todayTotal(
kind: "water",
syncedMl: CoachWidgetStore.load()?.asOf(Date()).waterMlToday ?? 0
)
if ref == "water" {
return .result(dialog: "Noté. \(Self.enLitres(total)) d'eau aujourd'hui.")
}
return .result(dialog: "Noté.")
}
/// « 1,25 L » plutôt que « 1250 ml » : Siri le lit à voix haute.
static func enLitres(_ ml: Double) -> String {
let litres = ml / 1000
let f = NumberFormatter()
f.numberStyle = .decimal
f.maximumFractionDigits = litres < 1 ? 2 : 1
return (f.string(from: NSNumber(value: litres)) ?? "\(litres)") + " L"
}
}
// MARK: - Où j'en suis
/// Répond « combien j'ai bu aujourd'hui », sans ouvrir l'app.
///
/// ⚠️ Cet intent ne fait que LIRE ce que l'appareil sait déjà : le snapshot
/// poussé par l'app plus la file locale. Il n'interroge pas le serveur — une
/// extension n'en a pas le droit, et Siri doit répondre tout de suite.
@available(iOS 17.0, watchOS 10.0, *)
struct HydrationStatusIntent: AppIntent {
static var title: LocalizedStringResource = "Où j'en suis côté hydratation"
static var description = IntentDescription(
"Dit combien d'eau a été notée aujourd'hui, et la cible du jour."
)
static var openAppWhenRun: Bool = false
func perform() async throws -> some IntentResult & ProvidesDialog {
let snapshot = CoachWidgetStore.load()?.asOf(Date())
let total = CoachQuickLog.todayTotal(
kind: "water", syncedMl: snapshot?.waterMlToday ?? 0)
guard let objectif = snapshot?.waterGoalMl, objectif > 0 else {
// Pas de cible connue : on donne le total, sans inventer de reste.
return .result(dialog: "\(LogDrinkIntent.enLitres(total)) d'eau aujourd'hui.")
}
let reste = max(0, objectif - total)
if reste <= 0 {
return .result(dialog: "\(LogDrinkIntent.enLitres(total)) : objectif atteint.")
}
return .result(dialog: "\(LogDrinkIntent.enLitres(total)) sur "
+ "\(LogDrinkIntent.enLitres(objectif)). Il reste "
+ "\(LogDrinkIntent.enLitres(reste)).")
}
}
// MARK: - La séance du jour
/// Dit la séance prévue aujourd'hui, telle que l'app l'a poussée.
///
/// ⛔ **Aucune borne en bpm n'est prononcée.** `TodaySession` n'en porte pas —
/// vérifié, elle a `sport`, `title`, `subtitle`, `done` — et il ne faut pas en
/// ajouter ici : une fourchette cardiaque dite 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.
/// `subtitle` (« 1h30 · Z2 ») est répété tel que l'app l'affiche, pas
/// reformulé : une zone nommée n'est pas une cible en battements.
@available(iOS 17.0, watchOS 10.0, *)
struct TodaySessionIntent: AppIntent {
static var title: LocalizedStringResource = "Ma séance du jour"
static var description = IntentDescription(
"Annonce le sport et la durée prévus aujourd'hui."
)
static var openAppWhenRun: Bool = false
func perform() async throws -> some IntentResult & ProvidesDialog {
guard let today = CoachWidgetStore.load()?.asOf(Date()).today else {
return .result(dialog: "Je n'ai pas encore ta séance du jour. "
+ "Ouvre l'app une fois.")
}
let sport = Self.sportEnFrancais(today.sport)
if today.done {
return .result(dialog: "\(sport) : \(today.title). Déjà faite aujourd'hui.")
}
// `subtitle` porte l'objectif court tel que l'app l'affiche
// (« 1h30 · Z2 ») : on le répète, on ne le reformule pas.
if let objectif = today.subtitle, !objectif.isEmpty {
return .result(dialog: "\(sport) : \(today.title), \(objectif).")
}
return .result(dialog: "\(sport) : \(today.title).")
}
static func sportEnFrancais(_ sport: String) -> String {
switch sport {
case "running": return "Course"
case "cycling": return "Vélo"
case "walking": return "Marche"
case "hiking": return "Randonnée"
case "strength": return "Renforcement"
case "mobility": return "Mobilité"
case "rest": return "Repos"
default: return sport.capitalized
}
}
}

View File

@@ -17,41 +17,14 @@ 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."
}
}
//
// ⚠️ `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

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])
}
}