Pendant natif du commit coach_sportif 01bad9e. Les 24 exercices sont cochables un par un pendant la séance, comme sur le web. - RoutineExercise + items dans RoutineBlock ; les exercices voyagent avec le snapshot, rien n'est codé en dur côté watchOS - l'en-tête de bloc est tappable : coche/décoche tous ses exercices d'un coup, pour enchaîner une série sans s'arrêter à chaque ligne - compteur n/N par bloc + progression globale en exercices - CoachRoutineBridge.sanitizeBlocks transmet désormais les items Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
168 lines
5.3 KiB
Swift
168 lines
5.3 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import os
|
|
|
|
private let routineLog = Logger(subsystem: "ch.hypnotruck.coach.watchkitapp", category: "routine")
|
|
|
|
/// Un exercice — c'est à ce grain que l'on coche, sur la montre comme sur le web.
|
|
struct RoutineExercise: Codable, Identifiable, Equatable {
|
|
let id: String
|
|
let name: String
|
|
let dose: String
|
|
}
|
|
|
|
/// 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"
|
|
let items: [RoutineExercise]
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, num, title, items
|
|
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" } }
|
|
|
|
/// Nombre total d'exercices — le dénominateur de la progression.
|
|
var totalExercises: Int { blocks.reduce(0) { $0 + $1.items.count } }
|
|
|
|
func doneCount(in block: RoutineBlock) -> Int {
|
|
block.items.filter { done.contains($0.id) }.count
|
|
}
|
|
|
|
/// Un bloc est fait quand tous ses exercices le sont. Dérivé, jamais stocké.
|
|
func isComplete(_ block: RoutineBlock) -> Bool {
|
|
!block.items.isEmpty && doneCount(in: block) == block.items.count
|
|
}
|
|
|
|
// 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(_ exerciseId: String) {
|
|
resetIfStale()
|
|
if done.contains(exerciseId) {
|
|
done.remove(exerciseId)
|
|
} else {
|
|
done.insert(exerciseId)
|
|
}
|
|
persist()
|
|
push()
|
|
}
|
|
|
|
/// Coche/décoche un bloc entier — pratique quand on enchaîne une série
|
|
/// complète sans s'arrêter à chaque exercice.
|
|
func toggleBlock(_ block: RoutineBlock) {
|
|
resetIfStale()
|
|
if isComplete(block) {
|
|
block.items.forEach { done.remove($0.id) }
|
|
} else {
|
|
block.items.forEach { done.insert($0.id) }
|
|
}
|
|
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())
|
|
}
|
|
}
|