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,101 @@
# Runbook Mac — Siri + widget Hydratation de la montre
**Écrit le 25/09/2026, depuis Linux.** Le code Swift est committé ; il reste les
étapes Xcode, qui ne peuvent pas se faire d'ici.
> ⛔ **Rien de ce code n'a été compilé.** `AppIntents`, `WidgetKit` et `SwiftUI`
> sont des frameworks Apple, absents de Swift pour Linux — le CLAUDE.md de ce
> dépôt le dit. Ce qui a été vérifié à la place, par lecture croisée des
> sources : que chaque symbole appelé existe (`CoachQuickLog.append`,
> `todayTotal`, `pending`, `CoachWidgetStore.load`, `nextMidnight`,
> `fallbackQuickDrinks`, `resolvedQuickDrinks`, `waterMlToday`, `waterGoalMl`,
> `asOf`), et qu'aucun symbole n'est déclaré deux fois. **Les erreurs de type,
> de concurrence et d'API n'apparaîtront qu'au build.**
## Ce que ça apporte
| | Avant | Après |
|---|---|---|
| Siri | rien — aucun `AppShortcutsProvider` | 5 raccourcis, 16 phrases |
| Montre | complications d'affichage | widget Smart Stack qui **note un verre** |
| Intents | 1, enfermé dans l'extension iPhone | 3, partagés entre 3 cibles |
## Fichiers ajoutés / modifiés
- **`ios/App/App/CoachIntents.swift`** *(nouveau)* — `LogDrinkIntent` (déplacé),
`HydrationStatusIntent`, `TodaySessionIntent`, `CoachQuickError`.
- **`ios/App/App/CoachAppShortcuts.swift`** *(nouveau)* — les phrases Siri.
- **`ios/App/CoachWatchWidgets/CoachWatchHydration.swift`** *(nouveau)* — widget
Smart Stack de la montre.
- `ios/App/CoachLiveActivity/CoachQuickWidget.swift` — l'intent en a été
**retiré** (il vivait là, invisible des deux autres cibles).
- `ios/App/CoachWatchWidgets/CoachWatchComplications.swift` — le widget rejoint
le `WidgetBundle`.
## Étapes Xcode
⚠️ **Quitter Xcode puis `git pull` d'abord** (règle COWORK : jamais de pull
Xcode ouvert).
### 1. Target Membership — c'est l'étape qui décide de tout
| Fichier | App | CoachLiveActivity | CoachWatchWidgets |
|---|:--:|:--:|:--:|
| `CoachIntents.swift` | ✅ | ✅ | ✅ |
| `CoachAppShortcuts.swift` | ✅ | ❌ | ❌ |
| `CoachWatchHydration.swift` | ❌ | ❌ | ✅ |
| `CoachQuickLog.swift` | ✅ | ✅ | ✅ *(à vérifier)* |
| `CoachWidgetSnapshot.swift` | ✅ | ✅ | ✅ *(déjà le cas)* |
⚠️ **`CoachAppShortcuts.swift` dans la cible App UNIQUEMENT.** Apple attend un
seul `AppShortcutsProvider` par application ; un second dans une extension
produirait au mieux un doublon.
⚠️ **`CoachQuickLog.swift` doit être dans `CoachWatchWidgets`**, sinon le widget
de la montre compile mais n'écrit nulle part. Son en-tête le réclamait déjà.
### 2. Vérifier l'App Group sur la cible Watch
`group.ch.hypnotruck.coach` sur **CoachWatch** ET **CoachWatchWidgets**.
Vérifié depuis Linux le 25/09 : les quatre `.entitlements` le portent et les
`CODE_SIGN_ENTITLEMENTS` sont câblés. À reconfirmer dans Signing & Capabilities.
### 3. Build, puis les vérifications qui comptent
1. **Siri** — dire « Note un verre d'eau dans Coach ». Attendu : « Noté.
X,XX L d'eau aujourd'hui », **sans que l'app s'ouvre**.
2. **Raccourcis** — l'app *Raccourcis* doit lister les 5 actions sous « Coach ».
3. **Smart Stack** — ajouter le widget Hydratation, toucher « +25 cl », vérifier
que le total monte **et** que le badge des saisies en attente s'incrémente.
4. **Synchro** — ouvrir l'app iPhone, vérifier que la saisie faite au poignet
remonte sur `coach.hypnotruck.ch`.
5. **Minuit** — le total doit repartir de zéro (c'est `asOf(_:)` qui l'assure,
pas le réveil de la timeline : le piège a déjà été payé côté iPhone).
## Les trois questions que seul le build tranchera
1. **Une complication de CADRAN accepte-t-elle un bouton d'App Intent, ou est-ce
réservé à la Smart Stack ?** La doc Apple n'était pas récupérable depuis
Linux. Par prudence, `.accessoryCircular` n'est ici qu'un **affichage** ; si
le cadran accepte l'interactivité, on pourra l'enrichir.
2. **Les phrases Siri passent-elles la validation ?** Chaque phrase contient
`\(.applicationName)`, comme Apple l'exige, et le nom supposé est « Coach »
(`CFBundleDisplayName`). Si le nom affiché diffère, les phrases ne se
déclencheront pas — c'est silencieux.
3. **`ProvidesDialog` sur watchOS.** Les intents rendent
`some IntentResult & ProvidesDialog` pour que Siri réponde à voix haute. À
confirmer que la cible watchOS l'accepte sans ajustement.
## Ce qui a été volontairement écarté
- ⛔ **Saisie des repas depuis la montre.** 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. Décidé avec Sylvain le 25/09.
- ⛔ **Un paramètre libre de volume dans les phrases Siri.** Un intent
paramétré oblige Siri à poser une question — ce qui coûte plus de temps que
de sortir le téléphone, et vide le raccourci de son intérêt. Les volumes sont
donc fixés (250 ml, 500 ml, café 60 ml).
- ⛔ **Toute borne en bpm dans les réponses de Siri.** `TodaySession` n'en porte
pas, 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.

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