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:
101
docs/siri-et-widget-watch-runbook-mac.md
Normal file
101
docs/siri-et-widget-watch-runbook-mac.md
Normal 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.
|
||||
89
ios/App/App/CoachAppShortcuts.swift
Normal file
89
ios/App/App/CoachAppShortcuts.swift
Normal 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
178
ios/App/App/CoachIntents.swift
Normal file
178
ios/App/App/CoachIntents.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
183
ios/App/CoachWatchWidgets/CoachWatchHydration.swift
Normal file
183
ios/App/CoachWatchWidgets/CoachWatchHydration.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user