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>
This commit is contained in:
Sylvain Bettinelli
2026-08-11 18:08:13 +00:00
parent 12b56a4d39
commit 3202e03a63
5 changed files with 302 additions and 2 deletions

View File

@@ -67,6 +67,15 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
) )
} }
var routine: CoachWidgetSnapshot.RoutineProgress?
if let r = call.getObject("routine"),
let total = (r["total"] as? NSNumber)?.intValue, total > 0 {
routine = CoachWidgetSnapshot.RoutineProgress(
done: (r["done"] as? NSNumber)?.intValue ?? 0,
total: total
)
}
var snapshot = CoachWidgetSnapshot(updatedAt: Date(), today: today, forme: forme) var snapshot = CoachWidgetSnapshot(updatedAt: Date(), today: today, forme: forme)
/* `doneToday` suit la même règle que la nutrition : absent de l'appel /* `doneToday` suit la même règle que la nutrition : absent de l'appel
on garde le précédent. Un appel partiel effacerait sinon l'activité on garde le précédent. Un appel partiel effacerait sinon l'activité
@@ -76,6 +85,10 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
veille resterait affichée indéfiniment. */ veille resterait affichée indéfiniment. */
snapshot.doneToday = done ?? (Calendar.current.isDateInToday(previous?.updatedAt ?? .distantPast) snapshot.doneToday = done ?? (Calendar.current.isDateInToday(previous?.updatedAt ?? .distantPast)
? previous?.doneToday : nil) ? previous?.doneToday : nil)
// Même garde du jour que `doneToday` : la routine se remet à zéro au
// réveil, un report aveugle afficherait 24/24 dès le matin.
snapshot.routine = routine ?? (Calendar.current.isDateInToday(previous?.updatedAt ?? .distantPast)
? previous?.routine : nil)
snapshot.waterMlToday = number("waterMlToday") ?? previous?.waterMlToday snapshot.waterMlToday = number("waterMlToday") ?? previous?.waterMlToday
snapshot.coffeeCountToday = snapshot.coffeeCountToday =
(call.getValue("coffeeCountToday") as? NSNumber)?.intValue ?? previous?.coffeeCountToday (call.getValue("coffeeCountToday") as? NSNumber)?.intValue ?? previous?.coffeeCountToday

View File

@@ -70,6 +70,11 @@ struct CoachWidgetSnapshot: Codable {
/// Optionnel : un snapshot écrit par une version antérieure reste lisible. /// Optionnel : un snapshot écrit par une version antérieure reste lisible.
var doneToday: DoneActivity? = nil var doneToday: DoneActivity? = nil
/// Avancement de la routine quotidienne (nil = non poussée).
/// Optionnel, comme les autres ajouts : un snapshot d'une version
/// antérieure reste décodable.
var routine: RoutineProgress? = nil
/// Séance planifiée du jour (nil = jour OFF / pas de séance). /// Séance planifiée du jour (nil = jour OFF / pas de séance).
struct TodaySession: Codable { struct TodaySession: Codable {
var sport: String // running, cycling, strength, mobility, hiking, rest var sport: String // running, cycling, strength, mobility, hiking, rest
@@ -78,6 +83,22 @@ struct CoachWidgetSnapshot: Codable {
var done: Bool // séance déjà réalisée aujourd'hui var done: Bool // séance déjà réalisée aujourd'hui
} }
/// Exercices de la routine cochés aujourd'hui, sur le total du jour.
/// Le grain est l'EXERCICE, pas le bloc : c'est celui de la page web et
/// de l'app Watch depuis le 2026-08-03, et deux grains concurrents
/// donneraient deux comptes différents du même geste.
struct RoutineProgress: Codable {
var done: Int
var total: Int
/// 01, borné. `total` nul ne doit jamais produire de division par zéro
/// ni une jauge pleine par accident.
var fraction: Double {
guard total > 0 else { return 0 }
return min(1, max(0, Double(done) / Double(total)))
}
}
/// Ce qui a été fait dans la journée : sport, et de quoi le qualifier. /// Ce qui a été fait dans la journée : sport, et de quoi le qualifier.
struct DoneActivity: Codable { struct DoneActivity: Codable {
var sport: String // running, cycling, walking, strength var sport: String // running, cycling, walking, strength

View File

@@ -14,5 +14,13 @@ struct CoachLiveActivityBundle: WidgetBundle {
CoachFormeScoreWidget() CoachFormeScoreWidget()
// Saisie rapide : App Intents (iOS 17+) pour l'eau et le café. // Saisie rapide : App Intents (iOS 17+) pour l'eau et le café.
if #available(iOS 17.0, *) { CoachQuickWidget() } if #available(iOS 17.0, *) { CoachQuickWidget() }
// Écran verrouillé (iOS 16+) : mêmes familles que les complications
// Watch. Trois widgets distincts plutôt qu'un seul multi-familles,
// pour qu'ils soient choisissables séparément dans chaque emplacement.
if #available(iOS 16.0, *) {
CoachLockFormeWidget()
CoachLockSessionWidget()
CoachRoutineWidget()
}
} }
} }

View File

@@ -288,3 +288,199 @@ struct CoachFormeScoreWidget: Widget {
.supportedFamilies([.systemSmall]) .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])
}
}

View File

@@ -146,7 +146,16 @@ struct CoachWatchSessionView: View {
let today = entry.snapshot?.today let today = entry.snapshot?.today
Group { Group {
if let s = today { if let s = today {
if family == .accessoryInline { 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)) Label(s.title, systemImage: watchSportSymbol(s.sport))
} else { // accessoryRectangular } else { // accessoryRectangular
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@@ -206,7 +215,59 @@ struct CoachWatchSessionWidget: Widget {
} }
.configurationDisplayName("Séance du jour") .configurationDisplayName("Séance du jour")
.description("La séance prévue aujourd'hui.") .description("La séance prévue aujourd'hui.")
.supportedFamilies([.accessoryRectangular, .accessoryInline]) .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])
} }
} }
@@ -217,5 +278,6 @@ struct CoachWatchWidgetsBundle: WidgetBundle {
var body: some Widget { var body: some Widget {
CoachWatchFormeWidget() CoachWatchFormeWidget()
CoachWatchSessionWidget() CoachWatchSessionWidget()
CoachWatchRoutineWidget()
} }
} }