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>
284 lines
11 KiB
Swift
284 lines
11 KiB
Swift
// 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()
|
||
}
|
||
}
|