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:
@@ -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