import Foundation /* Déroulé d'une séance structurée au poignet. * * **Pourquoi ce fichier existe.** WorkoutKit ne sait pas exécuter une séance * dans une app tierce : son seul point d'exécution public est * `WorkoutPlan.openInWorkoutApp()`, qui ouvre l'app *Exercice* d'Apple. Pour * guider les intervalles du plan coach dans NOTRE app, la machine à états doit * être écrite ici. * * Le moteur est **volontairement pur** — aucun import HealthKit, aucun timer, * aucune horloge interne. Il ne fait que répondre à la question « où en * sommes-nous ? » à partir du temps et de la distance que l'appelant lui * pousse. Trois conséquences : * * - il se teste intégralement sur Linux (cf. `tests-linux/`), là où * `WorkoutManager` ne le peut pas ; * - le temps de référence est `HKLiveWorkoutBuilder.elapsedTime`, qui * **exclut déjà les pauses** : le moteur n'a donc rien à savoir de la * pause, ni de l'auto-pause de la montre ; * - il est rejouable : réinjecter la même suite de ticks redonne le même * déroulé, ce qui rend la reprise après crash triviale * (`handleActiveWorkoutRecovery`). */ // MARK: - Modèle /// Objectif de fin d'une étape. /// /// `open` décrit une étape qui ne se termine que sur ordre de l'utilisateur — /// l'échauffement libre « pars quand tu es prêt », ou le retour au calme. public enum StepGoal: Equatable, Sendable { case time(TimeInterval) case distance(Double) // mètres case open } /// Une étape du plan, telle qu'elle arrive de l'iPhone. /// /// Les bornes de FC sont transportées **en bpm ET en numéro de zone**. Les deux /// sont nécessaires : la zone sert à l'affichage (« Z2 »), les bpm sont la seule /// cible exécutable, et elles proviennent de `app.current_zones()` — zones /// Karvonen sous bêtabloquant, jamais un % de FCmax générique. public struct CoachPlanStep: Codable, Equatable, Sendable { public enum Kind: String, Codable, Sendable { case warmup, work, recovery, cooldown } public var kind: Kind public var durationSec: Double? public var distanceM: Double? public var hrZone: Int? public var hrMinBpm: Double? public var hrMaxBpm: Double? public var label: String? enum CodingKeys: String, CodingKey { case kind case durationSec = "duration_sec" case distanceM = "distance_m" case hrZone = "hr_zone" case hrMinBpm = "hr_bpm_min" case hrMaxBpm = "hr_bpm_max" case label } public init(kind: Kind, durationSec: Double? = nil, distanceM: Double? = nil, hrZone: Int? = nil, hrMinBpm: Double? = nil, hrMaxBpm: Double? = nil, label: String? = nil) { self.kind = kind self.durationSec = durationSec self.distanceM = distanceM self.hrZone = hrZone self.hrMinBpm = hrMinBpm self.hrMaxBpm = hrMaxBpm self.label = label } /// La durée prime sur la distance quand les deux sont fournies : le plan /// coach est écrit en temps, la distance n'est qu'une alternative. public var goal: StepGoal { if let d = durationSec, d > 0 { return .time(d) } if let m = distanceM, m > 0 { return .distance(m) } return .open } /// Libellé affichable, avec repli sur le type d'étape. Le plan porte les /// consignes réelles dans `label` (« cadence 170+, foulée courte ») : c'est /// cette intention qu'on veut au poignet, pas un « Course » générique. public var displayLabel: String { if let l = label?.trimmingCharacters(in: .whitespacesAndNewlines), !l.isEmpty { return l } switch kind { case .warmup: return "Échauffement" case .work: return "Effort" case .recovery: return "Récupération" case .cooldown: return "Retour au calme" } } } /// La séance du jour, poussée par l'iPhone. public struct CoachSessionPlan: Codable, Equatable, Sendable { public var date: String public var sport: String public var title: String? public var steps: [CoachPlanStep] enum CodingKeys: String, CodingKey { case date, sport, title, steps } public init(date: String, sport: String, title: String? = nil, steps: [CoachPlanStep]) { self.date = date self.sport = sport self.title = title self.steps = steps } /// Durée totale prévue. `nil` dès qu'une étape est ouverte ou en distance : /// mieux vaut ne rien annoncer qu'annoncer un total faux. public var plannedDurationSec: TimeInterval? { var total: TimeInterval = 0 for step in steps { guard case .time(let d) = step.goal else { return nil } total += d } return total } } // MARK: - Moteur /// Ce que le moteur signale à l'appelant entre deux ticks. /// /// Les événements sont **rendus, jamais joués** par le moteur : c'est /// `WorkoutManager` qui décide d'un haptique ou d'un `HKWorkoutEvent`. Cette /// séparation est ce qui garde le moteur testable. public enum IntervalEvent: Equatable, Sendable { case stepStarted(index: Int) case stepFinished(index: Int) case planFinished /// Émis une seule fois par étape, à mi-parcours d'une étape mesurable. case halfway(index: Int) /// Vrai si l'événement fait changer d'étape. /// /// L'annonce de mi-parcours voyage dans le même flux que les transitions — /// pratique pour l'appelant, qui n'a qu'une boucle à écrire — mais elle ne /// déplace rien. Sans ce distinguo, « rien n'a bougé » et « aucun événement » /// se confondent, et c'est exactement l'erreur que les premiers tests de ce /// fichier ont commise. public var isTransition: Bool { switch self { case .stepStarted, .stepFinished, .planFinished: return true case .halfway: return false } } } extension Array where Element == IntervalEvent { /// Les seuls événements qui déplacent le curseur de séance. public var transitions: [IntervalEvent] { filter(\.isTransition) } } /// Instantané de progression, destiné à l'affichage. public struct IntervalProgress: Equatable, Sendable { public var stepIndex: Int public var step: CoachPlanStep? public var nextStep: CoachPlanStep? /// Temps passé dans l'étape courante (secondes actives). public var elapsedInStep: TimeInterval /// Temps restant, `nil` pour une étape ouverte ou en distance. public var remainingInStep: TimeInterval? /// Distance restante en mètres, `nil` si l'étape n'est pas en distance. public var remainingDistanceM: Double? /// 0…1, `nil` pour une étape ouverte. public var fraction: Double? public var isFinished: Bool } /// Machine à états du déroulé. `struct` mutable : pas d'état caché, copiable, /// donc inspectable dans les tests. public struct IntervalEngine: Equatable, Sendable { public let steps: [CoachPlanStep] public private(set) var index: Int = 0 public private(set) var isFinished: Bool = false /// Origine de l'étape courante, dans le référentiel du builder. private var stepStartElapsed: TimeInterval = 0 private var stepStartDistance: Double = 0 private var lastElapsed: TimeInterval = 0 private var lastDistance: Double = 0 private var halfwayAnnounced = false public init(steps: [CoachPlanStep]) { self.steps = steps self.isFinished = steps.isEmpty } public init(plan: CoachSessionPlan) { self.init(steps: plan.steps) } public var currentStep: CoachPlanStep? { guard !isFinished, steps.indices.contains(index) else { return nil } return steps[index] } public var nextStep: CoachPlanStep? { let n = index + 1 guard !isFinished, steps.indices.contains(n) else { return nil } return steps[n] } /// Pousse l'état de la séance et récupère les transitions franchies. /// /// `elapsed` est le temps **actif** (`HKLiveWorkoutBuilder.elapsedTime`), /// `distance` la distance cumulée en mètres depuis le départ. /// /// ⚠️ Une boucle, pas un `if` : un tick peut arriver en retard — l'app est /// suspendue poignet baissé, la collecte HealthKit est irrégulière — et /// franchir **plusieurs** étapes courtes d'un coup. Traiter une seule /// transition par tick ferait dériver le déroulé sans que rien ne le dise. @discardableResult public mutating func update(elapsed: TimeInterval, distance: Double) -> [IntervalEvent] { guard !isFinished else { return [] } // Le temps actif ne recule pas ; la distance non plus. Un recul signale // un appelant fautif : on borne plutôt que de produire des durées // négatives qui se propageraient dans l'affichage. lastElapsed = max(elapsed, lastElapsed) lastDistance = max(distance, lastDistance) var events: [IntervalEvent] = [] if !hasStarted { hasStarted = true events.append(.stepStarted(index: 0)) } while !isFinished, let step = currentStep, isComplete(step) { events.append(.stepFinished(index: index)) advanceIndex(at: lastElapsed, distance: lastDistance) if isFinished { events.append(.planFinished) } else { events.append(.stepStarted(index: index)) } } if !halfwayAnnounced, let f = progress().fraction, f >= 0.5, !isFinished { halfwayAnnounced = true events.append(.halfway(index: index)) } return events } /// Termine l'étape courante sur ordre de l'utilisateur (bouton « suivant », /// ou fin d'une étape ouverte). @discardableResult public mutating func advanceManually() -> [IntervalEvent] { guard !isFinished, currentStep != nil else { return [] } var events: [IntervalEvent] = [.stepFinished(index: index)] advanceIndex(at: lastElapsed, distance: lastDistance) events.append(isFinished ? .planFinished : .stepStarted(index: index)) return events } public func progress() -> IntervalProgress { guard !isFinished, let step = currentStep else { return IntervalProgress(stepIndex: index, step: nil, nextStep: nil, elapsedInStep: 0, remainingInStep: nil, remainingDistanceM: nil, fraction: nil, isFinished: true) } let inStep = max(0, lastElapsed - stepStartElapsed) var remaining: TimeInterval? var remainingDistance: Double? var fraction: Double? switch step.goal { case .time(let target): remaining = max(0, target - inStep) fraction = target > 0 ? min(1, inStep / target) : nil case .distance(let target): let done = max(0, lastDistance - stepStartDistance) remainingDistance = max(0, target - done) fraction = target > 0 ? min(1, done / target) : nil case .open: break } return IntervalProgress(stepIndex: index, step: step, nextStep: nextStep, elapsedInStep: inStep, remainingInStep: remaining, remainingDistanceM: remainingDistance, fraction: fraction, isFinished: false) } // MARK: Privé private var hasStarted = false private func isComplete(_ step: CoachPlanStep) -> Bool { switch step.goal { case .time(let target): return (lastElapsed - stepStartElapsed) >= target case .distance(let target): return (lastDistance - stepStartDistance) >= target case .open: // Une étape ouverte ne se termine JAMAIS toute seule : seul // `advanceManually()` la clôt. Sinon la séance défilerait d'un coup. return false } } /// Fait démarrer l'étape suivante à la frontière **théorique** de l'étape /// qui vient de finir, pas à l'instant du tick. /// /// ⚠️ C'est la subtilité de tout ce fichier. Si un tick arrive 3 s après la /// fin d'un intervalle d'une minute, caler le départ de la suivante sur ce /// tick lui offre 3 s de rab — et l'erreur **s'accumule** à chaque /// répétition. Sur un 9×(1'/1'), le décalage final se compte en dizaines de /// secondes. private mutating func advanceIndex(at elapsed: TimeInterval, distance: Double) { if let step = currentStep { switch step.goal { case .time(let target): stepStartElapsed += target stepStartDistance = distance case .distance(let target): stepStartDistance += target stepStartElapsed = elapsed case .open: stepStartElapsed = elapsed stepStartDistance = distance } } // Une étape ouverte ou terminée à la main ne peut pas laisser l'origine // derrière le tick courant. stepStartElapsed = min(stepStartElapsed, elapsed) stepStartDistance = min(stepStartDistance, distance) index += 1 halfwayAnnounced = false if index >= steps.count { isFinished = true } } }