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>
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import WidgetKit
|
||||
import WatchConnectivity
|
||||
|
||||
@objc(CoachWidgetBridgePlugin)
|
||||
public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
@@ -42,11 +43,28 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
label: f["label"] as? String
|
||||
)
|
||||
}
|
||||
CoachWidgetStore.save(CoachWidgetSnapshot(updatedAt: Date(), today: today, forme: forme))
|
||||
let snapshot = CoachWidgetSnapshot(updatedAt: Date(), today: today, forme: forme)
|
||||
CoachWidgetStore.save(snapshot)
|
||||
if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() }
|
||||
pushToWatch(snapshot)
|
||||
call.resolve(["ok": true])
|
||||
}
|
||||
|
||||
/// Envoie le snapshot à l'Apple Watch pour ses complications. L'App Group
|
||||
/// n'est PAS partagé entre appareils → la complication Watch lit le conteneur
|
||||
/// de SA montre, alimenté ici via WatchConnectivity.
|
||||
/// On n'est PAS le delegate WCSession (c'est CoachLiveBridge) : on se contente
|
||||
/// d'émettre, ce qui ne requiert pas d'être delegate, juste une session activée.
|
||||
private func pushToWatch(_ snapshot: CoachWidgetSnapshot) {
|
||||
guard WCSession.isSupported() else { return }
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated,
|
||||
let data = try? JSONEncoder().encode(snapshot) else { return }
|
||||
// updateApplicationContext : dernier état, sans budget (≠ transferCurrent
|
||||
// ComplicationUserInfo qui réveille la complication mais est rationné ~50/j).
|
||||
try? session.updateApplicationContext(["widgetSnapshot": data])
|
||||
}
|
||||
|
||||
@objc func clear(_ call: CAPPluginCall) {
|
||||
CoachWidgetStore.clear()
|
||||
if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
import WidgetKit
|
||||
import os
|
||||
|
||||
private let connLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "connectivity")
|
||||
@@ -78,4 +79,26 @@ extension ConnectivityManager: WCSessionDelegate {
|
||||
queue.async { [weak self] in self?.flushLocked() }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Réception du snapshot widget (iPhone → Watch) ───
|
||||
// L'iPhone (CoachWidgetBridge) pousse les données du jour ; on les persiste
|
||||
// dans l'App Group de CETTE montre et on recharge les complications.
|
||||
|
||||
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
storeWidgetSnapshot(applicationContext)
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
storeWidgetSnapshot(userInfo)
|
||||
}
|
||||
|
||||
private func storeWidgetSnapshot(_ dict: [String: Any]) {
|
||||
guard let data = dict["widgetSnapshot"] as? Data,
|
||||
let snapshot = try? JSONDecoder().decode(CoachWidgetSnapshot.self, from: data) else { return }
|
||||
CoachWidgetStore.save(snapshot)
|
||||
connLog.info("widgetSnapshot reçu de l'iPhone -> complications rechargées")
|
||||
DispatchQueue.main.async {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
202
ios/App/CoachWatchWidgets/CoachWatchComplications.swift
Normal file
202
ios/App/CoachWatchWidgets/CoachWatchComplications.swift
Normal file
@@ -0,0 +1,202 @@
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user