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>
487 lines
19 KiB
Swift
487 lines
19 KiB
Swift
// CoachWidgets.swift
|
||
// Widgets d'écran d'accueil (cible CoachLiveActivity). Lisent le snapshot
|
||
// poussé par l'app dans l'App Group (CoachWidgetStore) — aucun accès réseau.
|
||
// Deux widgets : « Séance du jour » et « Score de forme ».
|
||
//
|
||
// ⚠️ Ce fichier appartient à la cible CoachLiveActivity. Il référence
|
||
// CoachWidgetSnapshot.swift, qui doit AUSSI appartenir à cette cible
|
||
// (Target Membership) — cf. docs/widgets-runbook-mac.md.
|
||
|
||
import WidgetKit
|
||
import SwiftUI
|
||
|
||
// MARK: - Timeline
|
||
|
||
struct CoachSnapshotEntry: TimelineEntry {
|
||
let date: Date
|
||
let snapshot: CoachWidgetSnapshot?
|
||
}
|
||
|
||
struct CoachWidgetProvider: TimelineProvider {
|
||
func placeholder(in context: Context) -> CoachSnapshotEntry {
|
||
CoachSnapshotEntry(date: Date(), snapshot: nil)
|
||
}
|
||
|
||
func getSnapshot(in context: Context, completion: @escaping (CoachSnapshotEntry) -> Void) {
|
||
completion(CoachSnapshotEntry(date: Date(), snapshot: CoachWidgetStore.load()))
|
||
}
|
||
|
||
func getTimeline(in context: Context, completion: @escaping (Timeline<CoachSnapshotEntry>) -> Void) {
|
||
let entry = CoachSnapshotEntry(date: Date(), snapshot: CoachWidgetStore.load())
|
||
// L'app rafraîchit explicitement (WidgetCenter.reloadAllTimelines) à
|
||
// chaque setSnapshot. On planifie en plus un refresh horaire de courtoisie.
|
||
let next = Calendar.current.date(byAdding: .hour, value: 1, to: Date())
|
||
?? Date().addingTimeInterval(3600)
|
||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||
}
|
||
}
|
||
|
||
// MARK: - Palette / icônes (alignées sur les couleurs sport de l'app)
|
||
|
||
private func sportColor(_ sport: String) -> Color {
|
||
switch sport {
|
||
case "running": return Color(red: 1.00, green: 0.45, blue: 0.21)
|
||
case "cycling": return Color(red: 0.20, green: 0.78, blue: 0.35)
|
||
case "strength": return Color(red: 0.69, green: 0.32, blue: 0.87)
|
||
case "mobility": return Color(red: 0.35, green: 0.78, blue: 0.98)
|
||
case "hiking", "walking": return Color(red: 0.80, green: 0.62, blue: 0.30)
|
||
default: return Color.gray
|
||
}
|
||
}
|
||
|
||
private func sportSymbol(_ 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 sportLabel(_ 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
|
||
}
|
||
}
|
||
|
||
// Applique le fond container (requis iOS 17+) sans casser le build < 17.
|
||
private extension View {
|
||
@ViewBuilder
|
||
func coachWidgetBackground() -> some View {
|
||
if #available(iOS 17.0, *) {
|
||
self.containerBackground(.fill.tertiary, for: .widget)
|
||
} else {
|
||
self.padding()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Widget « Séance du jour »
|
||
|
||
struct CoachTodaySessionEntryView: View {
|
||
var entry: CoachSnapshotEntry
|
||
@Environment(\.widgetFamily) private var family
|
||
|
||
var body: some View {
|
||
let today = entry.snapshot?.today
|
||
Group {
|
||
if let s = today {
|
||
if family == .systemMedium {
|
||
mediumView(s)
|
||
} else {
|
||
smallView(s)
|
||
}
|
||
} else {
|
||
emptyView
|
||
}
|
||
}
|
||
.coachWidgetBackground()
|
||
.widgetURL(URL(string: "https://coach.hypnotruck.ch/calendar"))
|
||
}
|
||
|
||
private func header(_ s: CoachWidgetSnapshot.TodaySession) -> some View {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: sportSymbol(s.sport))
|
||
.foregroundStyle(sportColor(s.sport))
|
||
Text(sportLabel(s.sport).uppercased())
|
||
.font(.caption2.weight(.bold))
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
if s.done {
|
||
Image(systemName: "checkmark.seal.fill")
|
||
.foregroundStyle(.green)
|
||
.font(.caption)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func smallView(_ s: CoachWidgetSnapshot.TodaySession) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
header(s)
|
||
Spacer(minLength: 0)
|
||
Text(s.title)
|
||
.font(.headline)
|
||
.lineLimit(2)
|
||
.minimumScaleFactor(0.8)
|
||
if let sub = s.subtitle, !sub.isEmpty {
|
||
Text(sub).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||
}
|
||
|
||
private func mediumView(_ s: CoachWidgetSnapshot.TodaySession) -> some View {
|
||
HStack(spacing: 14) {
|
||
ZStack {
|
||
Circle().fill(sportColor(s.sport).opacity(0.18))
|
||
Image(systemName: sportSymbol(s.sport))
|
||
.font(.title2)
|
||
.foregroundStyle(sportColor(s.sport))
|
||
}
|
||
.frame(width: 56, height: 56)
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Séance du jour")
|
||
.font(.caption2.weight(.bold))
|
||
.foregroundStyle(.secondary)
|
||
Text(s.title).font(.headline).lineLimit(2)
|
||
if let sub = s.subtitle, !sub.isEmpty {
|
||
Text(sub).font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
|
||
}
|
||
if s.done {
|
||
Label("Réalisée", systemImage: "checkmark.seal.fill")
|
||
.font(.caption2).foregroundStyle(.green)
|
||
}
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||
}
|
||
|
||
/// Rien au plan. Deux situations très différentes, longtemps confondues :
|
||
/// une vraie journée de repos, et une journée où le plan a été vidé parce
|
||
/// que l'activité a été faite autrement. Annoncer « JOUR OFF » à qui vient
|
||
/// de rouler 20 km efface son effort — d'où la bascule sur `doneToday`.
|
||
@ViewBuilder
|
||
private var emptyView: some View {
|
||
if let d = entry.snapshot?.doneToday {
|
||
doneView(d)
|
||
} else {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "moon.zzz.fill").foregroundStyle(.secondary)
|
||
Text("JOUR OFF").font(.caption2.weight(.bold)).foregroundStyle(.secondary)
|
||
}
|
||
Spacer(minLength: 0)
|
||
Text("Pas de séance prévue")
|
||
.font(.subheadline).foregroundStyle(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
private func doneView(_ d: CoachWidgetSnapshot.DoneActivity) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: sportSymbol(d.sport))
|
||
.foregroundStyle(sportColor(d.sport))
|
||
Text(sportLabel(d.sport).uppercased())
|
||
.font(.caption2.weight(.bold)).foregroundStyle(.secondary)
|
||
Spacer()
|
||
Image(systemName: "checkmark.seal.fill")
|
||
.foregroundStyle(.green).font(.caption)
|
||
}
|
||
Spacer(minLength: 0)
|
||
// Le sous-titre porte la substance (« 20,3 km · 56 min ») ; le
|
||
// titre d'une séance importée est souvent vide, d'où le repli.
|
||
Text(d.subtitle ?? d.title ?? "Séance réalisée")
|
||
.font(.headline).lineLimit(2).minimumScaleFactor(0.8)
|
||
if d.subtitle != nil, let t = d.title, !t.isEmpty {
|
||
Text(t).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
struct CoachTodaySessionWidget: Widget {
|
||
let kind = "CoachTodaySessionWidget"
|
||
var body: some WidgetConfiguration {
|
||
StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in
|
||
CoachTodaySessionEntryView(entry: entry)
|
||
}
|
||
.configurationDisplayName("Séance du jour")
|
||
.description("La séance prévue aujourd'hui dans ton programme.")
|
||
.supportedFamilies([.systemSmall, .systemMedium])
|
||
}
|
||
}
|
||
|
||
// MARK: - Widget « Score de forme »
|
||
|
||
struct CoachFormeScoreEntryView: View {
|
||
var entry: CoachSnapshotEntry
|
||
|
||
private func ringColor(_ score: Int) -> Color {
|
||
switch score {
|
||
case 75...: return Color(red: 0.20, green: 0.78, blue: 0.35)
|
||
case 50..<75: return Color(red: 1.00, green: 0.80, blue: 0.0)
|
||
default: return Color(red: 1.00, green: 0.27, blue: 0.23)
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
let forme = entry.snapshot?.forme
|
||
VStack(spacing: 8) {
|
||
Text("FORME")
|
||
.font(.caption2.weight(.bold))
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
Spacer(minLength: 0)
|
||
if let score = forme?.score {
|
||
ZStack {
|
||
Circle().stroke(Color.secondary.opacity(0.18), lineWidth: 9)
|
||
Circle()
|
||
.trim(from: 0, to: min(max(Double(score) / 100.0, 0), 1))
|
||
.stroke(ringColor(score), style: StrokeStyle(lineWidth: 9, lineCap: .round))
|
||
.rotationEffect(.degrees(-90))
|
||
VStack(spacing: 0) {
|
||
Text("\(score)").font(.system(size: 30, weight: .bold, design: .rounded))
|
||
if let l = forme?.label, !l.isEmpty {
|
||
Text(l).font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else {
|
||
VStack(spacing: 4) {
|
||
Image(systemName: "bolt.heart")
|
||
.font(.title)
|
||
.foregroundStyle(.secondary)
|
||
Text("Pas de score")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
.coachWidgetBackground()
|
||
.widgetURL(URL(string: "https://coach.hypnotruck.ch/forme"))
|
||
}
|
||
}
|
||
|
||
struct CoachFormeScoreWidget: Widget {
|
||
let kind = "CoachFormeScoreWidget"
|
||
var body: some WidgetConfiguration {
|
||
StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in
|
||
CoachFormeScoreEntryView(entry: entry)
|
||
}
|
||
.configurationDisplayName("Score de forme")
|
||
.description("Ton score de forme / récupération du jour.")
|
||
.supportedFamilies([.systemSmall])
|
||
}
|
||
}
|
||
|
||
// MARK: - Écran verrouillé (iOS 16+) : familles accessory*
|
||
//
|
||
// Les mêmes familles que les complications Apple Watch. Les vues sont
|
||
// volontairement écrites ici plutôt que partagées avec CoachWatchWidgets :
|
||
// les deux extensions ne partagent aucun fichier, et l'App Group n'est PAS
|
||
// commun entre appareils (la montre reçoit son snapshot par WatchConnectivity).
|
||
// Dupliquer trois petites vues coûte moins qu'un fichier de plus à câbler
|
||
// dans deux cibles.
|
||
|
||
/// Fond des widgets accessory : transparent, la pile de verrouillage fournit
|
||
/// le sien. `.fill.tertiary` (celui des widgets d'accueil) y ferait une dalle
|
||
/// opaque.
|
||
private extension View {
|
||
@ViewBuilder
|
||
func lockWidgetBackground() -> some View {
|
||
if #available(iOS 17.0, *) {
|
||
self.containerBackground(.clear, for: .widget)
|
||
} else {
|
||
self
|
||
}
|
||
}
|
||
}
|
||
|
||
@available(iOS 16.0, *)
|
||
struct CoachLockFormeView: View {
|
||
@Environment(\.widgetFamily) private var family
|
||
var entry: CoachSnapshotEntry
|
||
|
||
var body: some View {
|
||
let score = entry.snapshot?.forme?.score
|
||
let label = entry.snapshot?.forme?.label
|
||
Group {
|
||
switch family {
|
||
case .accessoryInline:
|
||
// Une ligne, sans mise en forme : le système impose son style.
|
||
Text(score.map { "Forme \($0)" } ?? "Forme —")
|
||
case .accessoryRectangular:
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("FORME").font(.caption2.weight(.bold)).foregroundStyle(.secondary)
|
||
Text(score.map(String.init) ?? "—").font(.title2.weight(.semibold))
|
||
if let l = label, !l.isEmpty {
|
||
Text(l).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
default: // .accessoryCircular
|
||
// Gauge borné 0-100 : le score est un pourcentage de forme, un
|
||
// dépassement d'échelle donnerait un anneau plein trompeur.
|
||
Gauge(value: Double(min(100, max(0, score ?? 0))), in: 0...100) {
|
||
Text("")
|
||
} currentValueLabel: {
|
||
Text(score.map(String.init) ?? "—")
|
||
}
|
||
.gaugeStyle(.accessoryCircular)
|
||
}
|
||
}
|
||
.lockWidgetBackground()
|
||
.widgetURL(URL(string: "https://coach.hypnotruck.ch/forme"))
|
||
}
|
||
}
|
||
|
||
@available(iOS 16.0, *)
|
||
struct CoachLockSessionView: View {
|
||
@Environment(\.widgetFamily) private var family
|
||
var entry: CoachSnapshotEntry
|
||
|
||
var body: some View {
|
||
Group {
|
||
if let s = entry.snapshot?.today {
|
||
if family == .accessoryInline {
|
||
Label(s.title, systemImage: sportSymbol(s.sport))
|
||
} else {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Label(sportLabel(s.sport).uppercased(), systemImage: sportSymbol(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)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
} else if let d = entry.snapshot?.doneToday {
|
||
// Plan vide ≠ journée vide — cf. CoachWidgetSnapshot.DoneActivity.
|
||
if family == .accessoryInline {
|
||
Label(d.subtitle ?? sportLabel(d.sport), systemImage: sportSymbol(d.sport))
|
||
} else {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Label(sportLabel(d.sport).uppercased(), systemImage: sportSymbol(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)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
} 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)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
.lockWidgetBackground()
|
||
.widgetURL(URL(string: "https://coach.hypnotruck.ch/calendar"))
|
||
}
|
||
}
|
||
|
||
/// Routine quotidienne : avancement au grain EXERCICE (14/24), le même que la
|
||
/// page web et l'app Watch. Un grain « bloc » afficherait un autre compte du
|
||
/// même geste.
|
||
@available(iOS 16.0, *)
|
||
struct CoachRoutineView: View {
|
||
@Environment(\.widgetFamily) private var family
|
||
var entry: CoachSnapshotEntry
|
||
|
||
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: 3) {
|
||
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)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
default: // .accessoryCircular
|
||
Gauge(value: r?.fraction ?? 0, in: 0...1) {
|
||
Text("")
|
||
} currentValueLabel: {
|
||
// Le total ne tient pas dans l'anneau : on montre le nombre
|
||
// d'exercices faits, la jauge porte la proportion.
|
||
Text(r.map { String($0.done) } ?? "—")
|
||
}
|
||
.gaugeStyle(.accessoryCircular)
|
||
}
|
||
}
|
||
.lockWidgetBackground()
|
||
.widgetURL(URL(string: "https://coach.hypnotruck.ch/routine"))
|
||
}
|
||
}
|
||
|
||
@available(iOS 16.0, *)
|
||
struct CoachLockFormeWidget: Widget {
|
||
let kind = "CoachLockFormeWidget"
|
||
var body: some WidgetConfiguration {
|
||
StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in
|
||
CoachLockFormeView(entry: entry)
|
||
}
|
||
.configurationDisplayName("Forme (écran verrouillé)")
|
||
.description("Ton score de forme, sur l'écran verrouillé.")
|
||
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||
}
|
||
}
|
||
|
||
@available(iOS 16.0, *)
|
||
struct CoachLockSessionWidget: Widget {
|
||
let kind = "CoachLockSessionWidget"
|
||
var body: some WidgetConfiguration {
|
||
StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in
|
||
CoachLockSessionView(entry: entry)
|
||
}
|
||
.configurationDisplayName("Séance (écran verrouillé)")
|
||
.description("La séance du jour, ou ce que tu as fait.")
|
||
.supportedFamilies([.accessoryRectangular, .accessoryInline])
|
||
}
|
||
}
|
||
|
||
@available(iOS 16.0, *)
|
||
struct CoachRoutineWidget: Widget {
|
||
let kind = "CoachRoutineWidget"
|
||
var body: some WidgetConfiguration {
|
||
StaticConfiguration(kind: kind, provider: CoachWidgetProvider()) { entry in
|
||
CoachRoutineView(entry: entry)
|
||
}
|
||
.configurationDisplayName("Routine du jour")
|
||
.description("Exercices de la routine cochés aujourd'hui.")
|
||
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||
}
|
||
}
|