Files
coach-ios/ios/App/CoachWatchWidgets/CoachWatchComplications.swift
Sylvain Bettinelli 3202e03a63 Trois widgets d'ecran verrouille et une complication Routine
Les familles accessory* n'etaient declarees que cote Watch, alors que ce sont
exactement celles de l'ecran verrouille iOS 16+. Les vues existaient donc
deja : il ne manquait que la declaration cote iPhone.

Ajoutes sur l'ecran verrouille : Forme (circulaire/rectangulaire/inline),
Seance (rectangulaire/inline, avec la bascule doneToday du commit precedent)
et Routine du jour. Sur la Watch : complication Routine, et la seance gagne la
famille circulaire (icone du sport, verte quand elle est faite).

Le snapshot gagne RoutineProgress {done, total} au grain EXERCICE — celui de
la page web et de l'app Watch depuis le 03/08. `fraction` borne le ratio et
protege de la division par zero ; un total nul ne doit pas remplir la jauge.
Meme garde du jour que doneToday : la routine repart a zero au reveil, un
report aveugle afficherait 24/24 des le matin.

Vues dupliquees plutot que partagees entre les deux extensions : elles ne
partagent aucun fichier et l'App Group n'est pas commun entre appareils. Trois
petites vues coutent moins qu'un fichier de plus a cabler dans deux cibles —
et pour la meme raison tout est ajoute a CoachWidgets.swift, deja membre de sa
cible : AUCUN fichier nouveau, donc rien a cabler dans le pbxproj.

⚠️ NON COMPILE — a builder au prochain passage sur le Mac, avec le correctif
« JOUR OFF » (12b56a4).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:08:13 +00:00

284 lines
11 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) {
completion(WatchEntry(date: Date(), snapshot: CoachWidgetStore.load()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<WatchEntry>) -> Void) {
let entry = WatchEntry(date: Date(), snapshot: CoachWidgetStore.load())
let next = Calendar.current.date(byAdding: .hour, value: 2, to: Date())
?? Date().addingTimeInterval(7200)
completion(Timeline(entries: [entry], policy: .after(next)))
}
}
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 func tint(_ s: Int) -> Color {
switch s {
case 75...: return .green
case 50..<75: return .yellow
default: return .red
}
}
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(s))
}
} 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()
}
}