Files
coach-ios/ios/App/CoachWatch/RoutineStore.swift
Sylvain Bettinelli f032ee2126 feat(watch): séance guidée au poignet
Pendant natif du lecteur web. Bouton Démarrer par bloc : un exercice à la fois,
décompte en anneau, passage auto, haptique à chaque transition, pause et
navigation. Pendant une routine au sol, la montre est bien plus pratique que
d'aller chercher le téléphone — c'est tout l'intérêt de la version watchOS.

Chaque exercice écoulé est coché via RoutineStore.toggle, donc renvoyé à
l'iPhone puis au serveur : à la fin le bloc est à jour sur les trois surfaces.

La durée par exercice est désormais transmise dans le snapshot (duration_sec,
optionnel pour rester compatible avec les snapshots déjà persistés) : la vue
guidée utilise la vraie durée au lieu de répartir celle du bloc.

RoutineGuidedView.swift est déjà référencé dans le pbxproj (target CoachWatch),
aucun Add Files à faire. Backup : project.pbxproj.bak-guided

NON COMPILÉ — à inclure au prochain build Xcode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:32:29 +00:00

176 lines
5.6 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
/// Optionnel : absent des snapshots persistés avant le 2026-08-03. La vue
/// guidée retombe alors sur une répartition de la durée du bloc.
let durationSec: Int?
enum CodingKeys: String, CodingKey {
case id, name, dose
case durationSec = "duration_sec"
}
}
/// 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())
}
}