Files
coach-ios/ios/App/CoachWatchWidgets/CoachWatchComplications.swift
Sylvain Bettinelli 98b1cd0c89 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>
2026-09-25 06:58:11 +00:00

310 lines
12 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// CoachWatchComplications.swift
// Complications Apple Watch (cible widget extension watchOS « CoachWatchWidgets »).
// Lisent le snapshot poussé par l'iPhone via WatchConnectivity et persisté dans
// l'App Group de CETTE montre (CoachWidgetStore) — cf. ConnectivityManager.swift
// côté CoachWatch + CoachWidgetBridge.pushToWatch côté iPhone.
//
// ⚠️ Cette cible doit être CRÉÉE dans Xcode (Watch Widget Extension) sur le Mac.
// Fichiers à inclure dans sa Target Membership : ce fichier + CoachWidgetSnapshot.swift.
// App Group group.ch.hypnotruck.coach à activer sur CoachWatch ET CoachWatchWidgets.
// Détails : docs/widgets-runbook-mac.md.
import WidgetKit
import SwiftUI
// MARK: - Helpers locaux (dupliqués : cible séparée des widgets iOS)
private func watchSportSymbol(_ sport: String) -> String {
switch sport {
case "cycling": return "bicycle"
case "walking": return "figure.walk"
case "hiking": return "figure.hiking"
case "strength": return "dumbbell.fill"
case "mobility": return "figure.yoga"
case "rest": return "moon.zzz.fill"
default: return "figure.run"
}
}
private func watchSportLabel(_ sport: String) -> String {
switch sport {
case "running": return "Course"
case "cycling": return "Vélo"
case "walking": return "Marche"
case "hiking": return "Rando"
case "strength": return "Renfo"
case "mobility": return "Mobilité"
case "rest": return "Repos"
default: return sport.capitalized
}
}
// MARK: - Timeline
struct WatchEntry: TimelineEntry {
let date: Date
let snapshot: CoachWidgetSnapshot?
}
struct WatchProvider: TimelineProvider {
func placeholder(in context: Context) -> WatchEntry {
WatchEntry(date: Date(), snapshot: nil)
}
func getSnapshot(in context: Context, completion: @escaping (WatchEntry) -> Void) {
let now = Date()
completion(WatchEntry(date: now, snapshot: CoachWidgetStore.load()?.asOf(now)))
}
/// Même correctif que les widgets iPhone : une entrée maintenant, une à
/// minuit avec le snapshot périmé remis à zéro. Sans elle, la complication
/// affichait les chiffres de la veille jusqu'à réouverture de l'app —
/// et sur une montre, c'est encore plus visible : elle est au poignet
/// avant que l'iPhone ne soit déverrouillé.
func getTimeline(in context: Context, completion: @escaping (Timeline<WatchEntry>) -> Void) {
let now = Date()
let stored = CoachWidgetStore.load()
let midnight = CoachWidgetSnapshot.nextMidnight(after: now)
let entries = [
WatchEntry(date: now, snapshot: stored?.asOf(now)),
WatchEntry(date: midnight, snapshot: stored?.asOf(midnight)),
]
completion(Timeline(entries: entries,
policy: .after(midnight.addingTimeInterval(60))))
}
}
private extension View {
@ViewBuilder
func watchWidgetBackground() -> some View {
if #available(watchOS 10.0, *) {
self.containerBackground(.clear, for: .widget)
} else {
self
}
}
}
// MARK: - Complication « Score de forme » (circular / corner)
struct CoachWatchFormeView: View {
@Environment(\.widgetFamily) private var family
var entry: WatchEntry
private var score: Int? { entry.snapshot?.forme?.score }
private var band: CoachWidgetSnapshot.FormeBand? { entry.snapshot?.forme?.resolvedBand }
/// Couleur de la bande servie par le serveur.
///
/// ⚠️ Les seuils vivaient ici (75 / 50) et ne correspondaient plus à ceux du
/// serveur (65 / 55 / 40). Le score étant borné à [25, 75], le vert était
/// inatteignable : sur 115 jours de production, il n'est jamais sorti, et le
/// rouge couvrait 68 jours. La bande arrive maintenant avec le snapshot ;
/// le repli vit dans `FormeScore.resolvedBand`, aligné sur le serveur.
private func tint(_ band: CoachWidgetSnapshot.FormeBand?) -> Color {
switch band {
case .great: return .green
case .good: return .mint
case .medium: return .yellow
case .low: return .red
case nil: return .gray
}
}
var body: some View {
Group {
if let s = score {
switch family {
case .accessoryCorner:
Text("\(s)")
.font(.title3.weight(.semibold))
.widgetCurvesContent(label: "Forme")
default: // accessoryCircular
Gauge(value: Double(s), in: 0...100) {
Image(systemName: "bolt.heart.fill")
} currentValueLabel: {
Text("\(s)")
}
.gaugeStyle(.accessoryCircular)
.tint(tint(band))
}
} else {
Image(systemName: "bolt.heart")
.font(.headline)
.foregroundStyle(.secondary)
}
}
.watchWidgetBackground()
.widgetURL(URL(string: "https://coach.hypnotruck.ch/forme"))
}
}
private extension View {
// .widgetLabel pour la famille .accessoryCorner (texte courbé autour du cadran).
@ViewBuilder
func widgetCurvesContent(label: String) -> some View {
self.widgetLabel(label)
}
}
struct CoachWatchFormeWidget: Widget {
let kind = "CoachWatchFormeWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in
CoachWatchFormeView(entry: entry)
}
.configurationDisplayName("Forme")
.description("Ton score de forme du jour.")
.supportedFamilies([.accessoryCircular, .accessoryCorner])
}
}
// MARK: - Complication « Séance du jour » (rectangular / inline)
struct CoachWatchSessionView: View {
@Environment(\.widgetFamily) private var family
var entry: WatchEntry
var body: some View {
let today = entry.snapshot?.today
Group {
if let s = today {
if family == .accessoryCircular {
// Le cadran ne tient pas un titre : l'icône du sport porte
// l'information, la pastille verte dit qu'elle est faite.
ZStack {
AccessoryWidgetBackground()
Image(systemName: watchSportSymbol(s.sport))
.font(.title3)
.foregroundStyle(s.done ? .green : .primary)
}
} else if family == .accessoryInline {
Label(s.title, systemImage: watchSportSymbol(s.sport))
} else { // accessoryRectangular
VStack(alignment: .leading, spacing: 2) {
Label(watchSportLabel(s.sport).uppercased(),
systemImage: watchSportSymbol(s.sport))
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
Text(s.title).font(.headline).lineLimit(1)
if let sub = s.subtitle, !sub.isEmpty {
Text(sub).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
} else if s.done {
Text("Réalisée").font(.caption2).foregroundStyle(.green)
}
}
}
} else if let d = entry.snapshot?.doneToday {
/* Rien au plan, mais quelque chose a été fait : le plan se vide
quand l'activité est remplacée (« j'ai fait du VTT »).
Afficher « Jour OFF » ce jour-là est faux au poignet de
quelqu'un qui vient de rentrer de sortie. */
if family == .accessoryInline {
Label(d.subtitle ?? watchSportLabel(d.sport),
systemImage: watchSportSymbol(d.sport))
} else {
VStack(alignment: .leading, spacing: 2) {
Label(watchSportLabel(d.sport).uppercased(),
systemImage: watchSportSymbol(d.sport))
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
Text(d.subtitle ?? d.title ?? "Séance réalisée")
.font(.headline).lineLimit(1)
Text("Réalisée").font(.caption2).foregroundStyle(.green)
}
}
} else {
if family == .accessoryInline {
Label("Jour OFF", systemImage: "moon.zzz.fill")
} else {
VStack(alignment: .leading, spacing: 2) {
Label("JOUR OFF", systemImage: "moon.zzz.fill")
.font(.caption2.weight(.bold)).foregroundStyle(.secondary)
Text("Pas de séance").font(.footnote)
}
}
}
}
.watchWidgetBackground()
.widgetURL(URL(string: "https://coach.hypnotruck.ch/calendar"))
}
}
struct CoachWatchSessionWidget: Widget {
let kind = "CoachWatchSessionWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in
CoachWatchSessionView(entry: entry)
}
.configurationDisplayName("Séance du jour")
.description("La séance prévue aujourd'hui.")
.supportedFamilies([.accessoryRectangular, .accessoryInline, .accessoryCircular])
}
}
// MARK: - Complication « Routine du jour » (circular / rectangular / inline)
/// Avancement au grain EXERCICE (14/24) — le même que la page web et l'app
/// Watch depuis le 2026-08-03. Un grain « bloc » donnerait un second compte
/// du même geste, et les deux finiraient par diverger.
struct CoachWatchRoutineView: View {
@Environment(\.widgetFamily) private var family
var entry: WatchEntry
var body: some View {
let r = entry.snapshot?.routine
let text = r.map { "\($0.done)/\($0.total)" } ?? "—"
Group {
switch family {
case .accessoryInline:
Label("Routine \(text)", systemImage: "figure.cooldown")
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 2) {
Label("ROUTINE", systemImage: "figure.cooldown")
.font(.caption2.weight(.bold)).foregroundStyle(.secondary)
Text(text).font(.headline)
if let r, r.total > 0 {
ProgressView(value: r.fraction).progressViewStyle(.linear)
}
}
default: // accessoryCircular
Gauge(value: r?.fraction ?? 0, in: 0...1) {
Image(systemName: "figure.cooldown")
} currentValueLabel: {
Text(r.map { String($0.done) } ?? "—")
}
.gaugeStyle(.accessoryCircular)
.tint(.mint)
}
}
.watchWidgetBackground()
.widgetURL(URL(string: "https://coach.hypnotruck.ch/routine"))
}
}
struct CoachWatchRoutineWidget: Widget {
let kind = "CoachWatchRoutineWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: WatchProvider()) { entry in
CoachWatchRoutineView(entry: entry)
}
.configurationDisplayName("Routine")
.description("Exercices de la routine cochés aujourd'hui.")
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
// MARK: - Bundle @main de l'extension
@main
struct CoachWatchWidgetsBundle: WidgetBundle {
var body: some Widget {
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()
}
}
}