Files
coach-ios/ios/App/CoachWatchWidgets/CoachWatchComplications.swift
Sylvain Bettinelli cc88431f6b feat(watch): complications Apple Watch (Forme + Séance du jour)
L'App Group n'étant pas partagé entre appareils, le snapshot transite par
WatchConnectivity : iPhone CoachWidgetBridge.pushToWatch (updateApplicationContext)
-> CoachWatch/ConnectivityManager reçoit -> App Group de la montre + reload des
complications. Nouvelles complications (CoachWatchWidgets/CoachWatchComplications.swift) :
Forme (accessoryCircular/corner, Gauge) + Séance du jour (accessoryRectangular/inline).

⚠️ Reste Mac : créer la target widget watchOS + App Group/capability + Target
Membership. Runbook complet dans docs/widgets-runbook-mac.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 05:50:29 +00:00

203 lines
7.0 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 == .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 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])
}
}
// MARK: - Bundle @main de l'extension
@main
struct CoachWatchWidgetsBundle: WidgetBundle {
var body: some Widget {
CoachWatchFormeWidget()
CoachWatchSessionWidget()
}
}