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 = [] @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()) } }