Signale par Sylvain le 2026-08-18 : passe minuit, l'hydratation, les calories et la seance de la veille restaient affichees comme celles du jour. Il fallait ouvrir l'app pour les voir retomber a zero. Le widget n'a AUCUN acces reseau : il rend ce que l'app lui a pousse la derniere fois qu'elle a tourne. Le snapshot portait deja `updatedAt` — rien ne le lisait. CoachQuickWidget programmait meme un reveil a minuit, avec le bon commentaire, mais relisait ensuite les memes chiffres sans regarder leur date : le rechargement avait lieu, la remise a zero non. - `CoachWidgetSnapshot.asOf(_:)` : vide ce qui decrit UNE journee (eau, cafes, kcal, routine, activite faite, score de forme) quand le snapshot date d'un autre jour. Conserve ce qui traverse les jours — objectifs et zones cardiaques. Les compteurs repartent a 0 et non a nil : au matin, « rien bu » est vrai, « je ne sais pas » ne l'est pas. - Les trois providers emettent une 2e entree datee de minuit. WidgetKit bascule seul, sans reveiller l'app. - `tomorrow` : la seance du lendemain, poussee par le pont web. Sans elle, vider la seance ferait annoncer JOUR OFF chaque nuit — l'agacement d'un incident precedent, repete tous les jours. ⚠️ Deuxieme occurrence trouvee en verifiant, et pire : CoachQuickSync.credit() ajoutait au total EXISTANT puis rehorodatait a maintenant. Sur un snapshot de la veille, 200 ml bus le matin donnaient « 2 200 ml » estampilles du jour — un total faux, que la peremption ne pouvait plus rattraper. ⚠️ NON COMPILE : pas de toolchain Swift ici. Build Xcode requis sur le Mac mini pour que le correctif arrive sur l'iPhone et la Watch.
506 lines
20 KiB
Swift
506 lines
20 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) {
|
||
let now = Date()
|
||
completion(CoachSnapshotEntry(date: now, snapshot: CoachWidgetStore.load()?.asOf(now)))
|
||
}
|
||
|
||
/// ⚠️ **DEUX entrées, et c'est le correctif.**
|
||
///
|
||
/// L'ancienne version n'en produisait qu'une, avec le snapshot brut, et
|
||
/// repassait une heure plus tard. Or repasser ne sert à rien : elle relisait
|
||
/// le MÊME snapshot périmé dans l'App Group et le réaffichait à l'identique.
|
||
/// Les chiffres de la veille restaient donc à l'écran jusqu'à ce que l'app
|
||
/// soit rouverte à la main.
|
||
///
|
||
/// La seconde entrée est datée de minuit et porte le snapshot passé par
|
||
/// `asOf(_:)`. WidgetKit bascule dessus tout seul à l'heure dite, **sans
|
||
/// réveiller l'app ni accéder au réseau** — ce qu'un widget ne peut de
|
||
/// toute façon pas faire.
|
||
func getTimeline(in context: Context, completion: @escaping (Timeline<CoachSnapshotEntry>) -> Void) {
|
||
let now = Date()
|
||
let stored = CoachWidgetStore.load()
|
||
let midnight = CoachWidgetSnapshot.nextMidnight(after: now)
|
||
let entries = [
|
||
CoachSnapshotEntry(date: now, snapshot: stored?.asOf(now)),
|
||
CoachSnapshotEntry(date: midnight, snapshot: stored?.asOf(midnight)),
|
||
]
|
||
// L'app rafraîchit explicitement (WidgetCenter.reloadAllTimelines) à
|
||
// chaque setSnapshot ; on redemande la main peu après minuit pour que
|
||
// le jour d'APRÈS soit à son tour préparé.
|
||
completion(Timeline(entries: entries,
|
||
policy: .after(midnight.addingTimeInterval(60))))
|
||
}
|
||
}
|
||
|
||
// 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])
|
||
}
|
||
}
|