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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
/// 0…1, 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…
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user