diff --git a/docs/siri-et-widget-watch-runbook-mac.md b/docs/siri-et-widget-watch-runbook-mac.md new file mode 100644 index 0000000..c38c2a0 --- /dev/null +++ b/docs/siri-et-widget-watch-runbook-mac.md @@ -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. diff --git a/ios/App/App/CoachAppShortcuts.swift b/ios/App/App/CoachAppShortcuts.swift new file mode 100644 index 0000000..2331908 --- /dev/null +++ b/ios/App/App/CoachAppShortcuts.swift @@ -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" + ) + } +} diff --git a/ios/App/App/CoachIntents.swift b/ios/App/App/CoachIntents.swift new file mode 100644 index 0000000..3e78f9e --- /dev/null +++ b/ios/App/App/CoachIntents.swift @@ -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 + } + } +} diff --git a/ios/App/CoachLiveActivity/CoachQuickWidget.swift b/ios/App/CoachLiveActivity/CoachQuickWidget.swift index f30209b..a57233f 100644 --- a/ios/App/CoachLiveActivity/CoachQuickWidget.swift +++ b/ios/App/CoachLiveActivity/CoachQuickWidget.swift @@ -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 diff --git a/ios/App/CoachWatchWidgets/CoachWatchComplications.swift b/ios/App/CoachWatchWidgets/CoachWatchComplications.swift index 23644a7..4dc268b 100644 --- a/ios/App/CoachWatchWidgets/CoachWatchComplications.swift +++ b/ios/App/CoachWatchWidgets/CoachWatchComplications.swift @@ -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() + } } } diff --git a/ios/App/CoachWatchWidgets/CoachWatchHydration.swift b/ios/App/CoachWatchWidgets/CoachWatchHydration.swift new file mode 100644 index 0000000..2dad9bd --- /dev/null +++ b/ios/App/CoachWatchWidgets/CoachWatchHydration.swift @@ -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) -> 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]) + } +}