feat(watch): routine quotidienne cochable sur l'Apple Watch
Pendant natif du backend coach_sportif (commit 772385b, déjà en prod). - CoachRoutineBridge (target App) : pousse blocs + état du jour vers la Watch via updateApplicationContext, relaie les coches Watch vers /api/routine/day puis notifie la WebView (event routineUpdated) - CoachWatch/RoutineStore : état persisté en UserDefaults, reset au changement de jour. Les blocs viennent de l'iPhone — rien codé en dur côté watchOS, donc modifier la routine côté web ne demande aucun rebuild - CoachWatch/RoutineView : liste cochable, progression, haptique, bouton de renvoi si la synchro a échoué - ConnectivityManager (watch) : sendRoutine() en sendMessage avec repli transferUserInfo (coche faite iPhone hors de portée -> livraison différée) ⚠️ CoachLiveBridge touché : routeIfNotLiveSample() aiguille les messages routineDone vers NotificationCenter. WCSession.delegate est unique côté iPhone, impossible d'en ajouter un second. Le flux live workout n'est pas modifié mais doit être re-vérifié au build. Les 3 nouveaux fichiers sont référencés dans project.pbxproj (bonne target) : aucun "Add Files" à faire sur le Mac. Runbook : docs/routine-watch-runbook-mac.md NON COMPILÉ — nécessite une session Xcode sur le Mac mini. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,31 @@ final class ConnectivityManager: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoie l'état COMPLET de la routine du jour vers l'iPhone, qui le POSTe
|
||||
/// au serveur. Retourne `false` si rien n'a pu partir (l'appelant affiche
|
||||
/// alors un bouton « Renvoyer »).
|
||||
///
|
||||
/// `sendMessage` quand l'iPhone est joignable, `transferUserInfo` sinon :
|
||||
/// la coche faite pendant une séance, iPhone resté à la maison, partira au
|
||||
/// prochain rapprochement.
|
||||
@discardableResult
|
||||
func sendRoutine(date: String, done: [String]) -> Bool {
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated else { return false }
|
||||
let payload: [String: Any] = ["routineDone": done, "date": date]
|
||||
|
||||
if session.isReachable {
|
||||
session.sendMessage(payload, replyHandler: nil) { _ in
|
||||
// Échec temps réel : bascule en livraison différée garantie.
|
||||
session.transferUserInfo(payload)
|
||||
}
|
||||
} else {
|
||||
session.transferUserInfo(payload)
|
||||
}
|
||||
connLog.info("routine envoyée à l'iPhone (\(done.count, privacy: .public) blocs)")
|
||||
return true
|
||||
}
|
||||
|
||||
/// À appeler sur `queue` uniquement.
|
||||
private func flushLocked() {
|
||||
guard !buffer.isEmpty else { return }
|
||||
@@ -86,10 +111,22 @@ extension ConnectivityManager: WCSessionDelegate {
|
||||
|
||||
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
storeWidgetSnapshot(applicationContext)
|
||||
storeRoutineSnapshot(applicationContext)
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
storeWidgetSnapshot(userInfo)
|
||||
storeRoutineSnapshot(userInfo)
|
||||
}
|
||||
|
||||
/// Routine quotidienne poussée par l'iPhone (`CoachRoutineBridge`).
|
||||
/// Les blocs voyagent avec l'état : la montre n'a aucune liste codée en dur,
|
||||
/// modifier la routine côté web ne demande donc pas de rebuild watchOS.
|
||||
private func storeRoutineSnapshot(_ dict: [String: Any]) {
|
||||
guard let snapshot = dict["routineSnapshot"] as? [String: Any] else { return }
|
||||
Task { @MainActor in
|
||||
RoutineStore.shared.apply(snapshot: snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private func storeWidgetSnapshot(_ dict: [String: Any]) {
|
||||
|
||||
@@ -24,6 +24,7 @@ struct ContentView: View {
|
||||
|
||||
struct ActivityPickerView: View {
|
||||
@EnvironmentObject private var workout: WorkoutManager
|
||||
@State private var showRoutine = false
|
||||
|
||||
private struct Activity: Identifiable {
|
||||
let id = UUID()
|
||||
@@ -54,6 +55,14 @@ struct ActivityPickerView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
Button {
|
||||
showRoutine = true
|
||||
} label: {
|
||||
Label("Routine du jour", systemImage: "figure.cooldown")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.tint(.cyan)
|
||||
|
||||
Button {
|
||||
workout.startSimulation()
|
||||
} label: {
|
||||
@@ -71,6 +80,9 @@ struct ActivityPickerView: View {
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.navigationTitle("Coach")
|
||||
.sheet(isPresented: $showRoutine) {
|
||||
RoutineView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
134
ios/App/CoachWatch/RoutineStore.swift
Normal file
134
ios/App/CoachWatch/RoutineStore.swift
Normal file
@@ -0,0 +1,134 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import os
|
||||
|
||||
private let routineLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "routine")
|
||||
|
||||
/// Un bloc de la routine quotidienne, tel que poussé par l'iPhone.
|
||||
struct RoutineBlock: Codable, Identifiable, Equatable {
|
||||
let id: String
|
||||
let num: Int
|
||||
let title: String
|
||||
let durationMin: Int
|
||||
let moment: String // "matin" | "après la course"
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, num, title
|
||||
case durationMin = "duration_min"
|
||||
case moment
|
||||
}
|
||||
}
|
||||
|
||||
/// État de la routine sur la montre.
|
||||
///
|
||||
/// Les blocs ne sont PAS codés en dur ici : ils viennent de l'iPhone (qui les
|
||||
/// tient du serveur). Une seule source de vérité — modifier la routine côté web
|
||||
/// n'oblige pas à rebuilder l'app Watch.
|
||||
///
|
||||
/// Persisté dans UserDefaults pour survivre au relancement de l'app Watch : sans
|
||||
/// ça, la montre afficherait un écran vide tant que l'iPhone n'a pas repoussé
|
||||
/// le contexte (ce qui peut prendre plusieurs minutes hors de portée).
|
||||
@MainActor
|
||||
final class RoutineStore: ObservableObject {
|
||||
static let shared = RoutineStore()
|
||||
|
||||
@Published private(set) var blocks: [RoutineBlock] = []
|
||||
@Published private(set) var done: Set<String> = []
|
||||
@Published private(set) var date: String = ""
|
||||
@Published private(set) var lastSyncFailed = false
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let kBlocks = "routine.blocks"
|
||||
private let kDone = "routine.done"
|
||||
private let kDate = "routine.date"
|
||||
|
||||
private init() {
|
||||
load()
|
||||
}
|
||||
|
||||
var isEmpty: Bool { blocks.isEmpty }
|
||||
|
||||
var morningBlocks: [RoutineBlock] { blocks.filter { $0.moment == "matin" } }
|
||||
var otherBlocks: [RoutineBlock] { blocks.filter { $0.moment != "matin" } }
|
||||
|
||||
// MARK: - Persistance locale
|
||||
|
||||
private func load() {
|
||||
if let data = defaults.data(forKey: kBlocks),
|
||||
let decoded = try? JSONDecoder().decode([RoutineBlock].self, from: data) {
|
||||
blocks = decoded
|
||||
}
|
||||
done = Set(defaults.stringArray(forKey: kDone) ?? [])
|
||||
date = defaults.string(forKey: kDate) ?? Self.todayISO()
|
||||
resetIfStale()
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(blocks) {
|
||||
defaults.set(data, forKey: kBlocks)
|
||||
}
|
||||
defaults.set(Array(done), forKey: kDone)
|
||||
defaults.set(date, forKey: kDate)
|
||||
}
|
||||
|
||||
/// Changement de jour : les coches d'hier ne doivent pas traîner.
|
||||
private func resetIfStale() {
|
||||
let today = Self.todayISO()
|
||||
if date != today {
|
||||
date = today
|
||||
done = []
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPhone → Watch
|
||||
|
||||
func apply(snapshot: [String: Any]) {
|
||||
if let blocksRaw = snapshot["blocks"] as? [[String: Any]],
|
||||
let data = try? JSONSerialization.data(withJSONObject: blocksRaw),
|
||||
let decoded = try? JSONDecoder().decode([RoutineBlock].self, from: data) {
|
||||
blocks = decoded
|
||||
}
|
||||
if let snapDate = snapshot["date"] as? String {
|
||||
date = snapDate
|
||||
}
|
||||
if let doneIds = snapshot["done"] as? [String] {
|
||||
done = Set(doneIds)
|
||||
}
|
||||
lastSyncFailed = false
|
||||
persist()
|
||||
routineLog.info("routine reçue de l'iPhone : \(self.blocks.count, privacy: .public) blocs, \(self.done.count, privacy: .public) faits")
|
||||
}
|
||||
|
||||
// MARK: - Watch → iPhone
|
||||
|
||||
func toggle(_ blockId: String) {
|
||||
resetIfStale()
|
||||
if done.contains(blockId) {
|
||||
done.remove(blockId)
|
||||
} else {
|
||||
done.insert(blockId)
|
||||
}
|
||||
persist()
|
||||
push()
|
||||
}
|
||||
|
||||
/// Renvoie l'état COMPLET (pas un delta) : si un message se perd, le suivant
|
||||
/// recale tout au lieu de laisser les deux côtés diverger.
|
||||
private func push() {
|
||||
let sent = ConnectivityManager.shared.sendRoutine(date: date, done: Array(done))
|
||||
lastSyncFailed = !sent
|
||||
}
|
||||
|
||||
/// Nouvelle tentative manuelle après un échec (bouton de la vue).
|
||||
func retry() {
|
||||
push()
|
||||
}
|
||||
|
||||
static func todayISO() -> String {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateFormat = "yyyy-MM-dd"
|
||||
fmt.timeZone = TimeZone.current
|
||||
return fmt.string(from: Date())
|
||||
}
|
||||
}
|
||||
118
ios/App/CoachWatch/RoutineView.swift
Normal file
118
ios/App/CoachWatch/RoutineView.swift
Normal file
@@ -0,0 +1,118 @@
|
||||
import SwiftUI
|
||||
import WatchKit
|
||||
|
||||
/// Routine quotidienne sur la montre : liste des blocs, cochables au doigt.
|
||||
/// Chaque coche part vers l'iPhone, qui la POSTe au serveur — l'état est donc
|
||||
/// partagé avec la page /routine et le widget de la home.
|
||||
struct RoutineView: View {
|
||||
@StateObject private var store = RoutineStore.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if store.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
.navigationTitle("Routine")
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "figure.cooldown")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Ouvre l'app Coach sur l'iPhone une fois pour recevoir la routine.")
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 6) {
|
||||
progressHeader
|
||||
|
||||
if !store.morningBlocks.isEmpty {
|
||||
sectionTitle("Le matin")
|
||||
ForEach(store.morningBlocks) { block in
|
||||
row(block)
|
||||
}
|
||||
}
|
||||
|
||||
if !store.otherBlocks.isEmpty {
|
||||
sectionTitle("Après la course")
|
||||
ForEach(store.otherBlocks) { block in
|
||||
row(block)
|
||||
}
|
||||
}
|
||||
|
||||
if store.lastSyncFailed {
|
||||
Button {
|
||||
store.retry()
|
||||
} label: {
|
||||
Label("Renvoyer vers l'iPhone", systemImage: "arrow.clockwise")
|
||||
.font(.footnote)
|
||||
}
|
||||
.tint(.orange)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var progressHeader: some View {
|
||||
let total = store.blocks.count
|
||||
let count = store.done.count
|
||||
return VStack(spacing: 4) {
|
||||
Text("\(count) / \(total) blocs")
|
||||
.font(.headline)
|
||||
.foregroundStyle(count == total && total > 0 ? .green : .primary)
|
||||
ProgressView(value: total > 0 ? Double(count) / Double(total) : 0)
|
||||
.tint(.cyan)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func sectionTitle(_ text: String) -> some View {
|
||||
Text(text.uppercased())
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
private func row(_ block: RoutineBlock) -> some View {
|
||||
let isDone = store.done.contains(block.id)
|
||||
return Button {
|
||||
store.toggle(block.id)
|
||||
WKInterfaceDevice.current().play(isDone ? .click : .success)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(isDone ? .green : .secondary)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(block.title)
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.leading)
|
||||
.strikethrough(isDone, color: .secondary)
|
||||
Text("\(block.durationMin) min")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.background(Color.gray.opacity(isDone ? 0.10 : 0.18), in: RoundedRectangle(cornerRadius: 8))
|
||||
.opacity(isDone ? 0.65 : 1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user