// // ExerciseProgressView.swift // Workouts // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import SwiftUI import UIKit /// Runs a single exercise as a horizontally-paged flow — the iPhone counterpart to the /// watch's `ExerciseProgressView`: /// /// [Ready] → Work₁ → Rest₁ → Work₂ → … → Workₙ → Finish /// /// A **Ready?** page leads in *only* when the exercise hasn't been started yet (a /// resumed exercise jumps straight to its first unfinished set). The **work** phase /// counts *up* (a stopwatch for the current set, tinted with the brand purple); the /// user swipes left when they're done. The **rest** phase counts *down* from the /// configurable rest time (light teal), buzzes once per second in the final three /// seconds, then auto-advances to the next work phase. Sliding past the final set /// reaches a **Finish** page offering **One More** (append a bonus set) and **Done** /// (which also auto-fires after a configurable countdown, completing the exercise). /// /// The paged flow occupies the **top half** of the screen; the bottom half shows the /// animated form-guide figure when the exercise has a bundled motion /// (`ExerciseFigureSlot`). Between the two halves sits the **live status panel** — /// heart rate (tinted by the target band on cardio runs), HR zone, calories, and the /// workout stopwatch in big glanceable digits. A row of phase dots tracks progress: /// purple for work, teal for rest, with the current phase drawn as a wider dash. struct ExerciseProgressView: View { @Environment(\.dismiss) private var dismiss @Environment(\.verticalSizeClass) private var verticalSizeClass @Environment(\.modelContext) private var modelContext @Environment(SyncEngine.self) private var sync /// Live watch-forwarded metrics (heart rate, calories, zone — see `LiveRunState`) /// — they drive the status panel between the two halves. @Environment(LiveRunState.self) private var liveRun /// The shared working workout document owned by the parent list. We mutate the /// matching log in place and ask the parent to persist each change — driving the UI /// from this doc (not the cache) avoids losing rapid edits to the read-after-write /// race the cache update lags behind. @Binding var doc: WorkoutDocument let logID: String let onChange: () -> Void /// True when this run was auto-advanced into from the previous exercise (flow mode). /// It suppresses the Ready page and begins the exercise immediately on appear — the /// whole point of a hands-free flowing routine. let enteredViaFlow: Bool /// Invoked at the terminal rest's end in flow mode to hand off to the next exercise; /// the parent `RunFlowView` swaps the presented log. Nil for a non-advancing run /// (the terminal page is then the normal Finish page that dismisses). var onAdvance: ((String) -> Void)? = nil /// Broadcasts the current flow position so the watch (if it has this run open) can follow /// it live (ephemeral; see `LiveProgress`). `onLiveEnded` fires when we leave the flow. /// Only *human* transitions are broadcast — an auto-advance (rest/timed-work end) isn't, /// since the other device reaches it independently off the same wall-clock anchors. Default /// no-ops, so the in-list driver stays purely durable; the propped-phone cover wires these. let onLive: (LiveProgress) -> Void let onLiveEnded: () -> Void /// Reflects the run's current position onto the iOS Live Activity (lock screen + Dynamic /// Island). Unlike `onLive` (watch, *human* transitions only), this fires on **every** page /// settle — human, auto-advance, or a followed watch frame — plus the initial page, since the /// Live Activity must track the current phase and countdown no matter who drove it. /// `onActivityEnded` fires when the flow is left, so the activity can be dismissed. Both /// default to no-ops, so the in-list driver only wires them where an activity is wanted. let onActivity: (LiveProgress) -> Void let onActivityEnded: () -> Void /// The latest live-run frame the *watch* sent for this run, to follow when it drives a /// transition (ephemeral; nil when the watch isn't driving). Applying it jumps our page /// without re-broadcasting or re-recording — the originating device owns the durable write. var incomingFrame: LiveProgress? /// Reads the exercise's setup/form cues aloud when the user taps Start, if /// `speakExerciseCues` is on. Optional so decoupled hosts (the screenshot rig) that /// don't inject `AppServices` simply stay silent. var speechAnnouncer: SpeechAnnouncer? = nil /// Rest length between sets, shared with the watch via the same defaults key. @AppStorage("restSeconds") private var restSeconds: Int = 45 /// Read aloud the setup/form cues when an exercise starts (hands-free). Off by default; /// opt in from Settings. Governs only the automatic cue — the library Speak button is /// always available. @AppStorage("speakExerciseCues") private var speakExerciseCues = false /// What the automatic narration says (only when `speakExerciseCues` is on): the concise /// coming-up/countdown cues, the setup & form read, or both. @AppStorage("spokenCueStyle") private var spokenCueStyle: SpokenCueStyle = .cues /// Auto-Done countdown on the Finish page — read so a broadcast frame carries the same /// end anchor the watch's mirror counts off. @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5 /// Display unit for the adjust affordance (stored weights stay unit-less). @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb /// Presents the compact adjust sheet for the just-completed set's entry. @State private var showingAdjust = false /// Planned set count for this run. `One More` bumps it (and the log's `sets`). @State private var setCount: Int @State private var currentPage: Int @State private var didRestorePage = false /// Why `currentPage` last changed, so the page observer knows whether to broadcast it. /// A human swipe leaves this `nil` (→ treated as human); programmatic moves set it. @State private var pageChangeCause: PageChangeCause? /// Highest remote frame we've applied — version plus its anchor tie-break (see /// `LiveProgress.isNewer`) — so a redelivery or collided stale frame doesn't re-jump. @State private var lastAppliedVersion = 0 @State private var lastAppliedStart = Date.distantPast /// Highest completed-set count this screen has itself recorded or followed via a live /// frame — the baseline `repairFromDurable` compares an absorbed doc against, so a /// durable echo of our own progress (or of a frame we followed) never triggers a jump. @State private var knownStateIndex: Int /// A page reached with a *known* wall-clock anchor counts its timer from that anchor /// instead of local `now`. Two sources: a remote frame (the sender's clock, so delivery /// latency can't desync the mirror) and a chained auto-advance (the finished phase's /// computed end, so a ticker that slept through the boundary — wrist-down throttling — /// can't bake its lateness into the next phase's timer). Scoped to one page; a human /// transition to another page self-anchors. @State private var pageAnchor: PageAnchor? private struct PageAnchor { let page: Int let start: Date let end: Date? } private enum PageChangeCause { case auto, remote } /// True when this run opened on a resumed set (in-progress) rather than the Ready /// page. Held in `@State` so it stays fixed for the life of the screen — the parent /// list re-inits this view whenever the workout file changes, and this must not flip /// mid-run. Such a run re-asserts its resume page after the first layout and ignores /// the transient TabView snap-to-0, so it isn't reset on open. @State private var startsResumed: Bool /// True when the exercise was already completed when this screen opened — it shows a /// static Completed page instead of dropping back into the timer flow. Fixed at init /// so completing the exercise from inside the flow doesn't swap the page mid-dismiss. @State private var startsCompleted: Bool /// True when the exercise was skipped (workout ended early) when this screen opened — /// it shows a static Skipped page instead of the timer flow. Fixed at init like /// `startsCompleted`. @State private var startsSkipped: Bool /// Set when *this* screen resolves the exercise (Done / flow hand-off), so the /// remote-flip observer doesn't mistake our own terminal write for a peer's. @State private var locallyResolved = false /// Where the resume landed, computed once on appear (with its timer anchor) so the /// post-layout re-assert jumps to the same page the anchor was built for. @State private var resumeTargetPage: Int? /// The leading header button. The default chevron dismisses like a nav back button; /// hosts that present modally (the live-mirror cover) swap in their own symbol/action. var closeSymbol: String = "chevron.backward" var onClose: (() -> Void)? = nil init(doc: Binding, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, speechAnnouncer: SpeechAnnouncer? = nil, enteredViaFlow: Bool = false, onAdvance: ((String) -> Void)? = nil, closeSymbol: String = "chevron.backward", onClose: (() -> Void)? = nil) { self._doc = doc self.logID = logID self.onChange = onChange self.onLive = onLive self.onLiveEnded = onLiveEnded self.onActivity = onActivity self.onActivityEnded = onActivityEnded self.incomingFrame = incomingFrame self.speechAnnouncer = speechAnnouncer self.enteredViaFlow = enteredViaFlow self.onAdvance = onAdvance self.closeSymbol = closeSymbol self.onClose = onClose let log = doc.wrappedValue.logs.first { $0.id == logID } let sets = max(1, log?.sets ?? 1) _setCount = State(initialValue: sets) _knownStateIndex = State(initialValue: max(0, log?.currentStateIndex ?? 0)) // The Ready page always leads the flow. A not-started run opens on it; an // in-progress run opens on its first unfinished set and re-asserts that page past // the TabView's snap-to-0 on first layout. let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue _startsResumed = State(initialValue: !notStarted) _startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue) _startsSkipped = State(initialValue: log?.status == WorkoutStatus.skipped.rawValue) // A flow-advanced entry has no Ready page (base 0), so page 0 is its first work set. let base = enteredViaFlow ? 0 : 1 // Resume on the first unfinished set's work page (clamped to the last set). let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1) let resume = base + completed * 2 _currentPage = State(initialValue: notStarted ? 0 : resume) // A resumed run refines the landing to the phase the exercise is actually in // (mid-rest lands on the rest page) with its wall-clock anchor, so the timer // continues instead of restarting. Computed here — not on appear — because the // paged TabView must *initialize* on this page: a post-layout backward jump // wedges its pager, which then ignores the next animated programmatic advance. if !notStarted, log?.status == WorkoutStatus.inProgress.rawValue { let refined = computeResume() _currentPage = State(initialValue: refined.page) _resumeTargetPage = State(initialValue: refined.page) _pageAnchor = State(initialValue: refined.anchor) } } private var log: WorkoutLogDocument? { doc.logs.first { $0.id == logID } } /// The **Ready?** page leads the flow so a resumed run can swipe back to it (which /// resets the exercise). A flow-advanced entry skips it — the exercise begins on /// appear, hands-free. private var showsReady: Bool { !enteredViaFlow } /// This routine runs as a continuous flow — finishing one exercise rests, then /// auto-advances into the next — rather than returning to the list per exercise. private var autoAdvance: Bool { doc.autoAdvance == true } /// Rest length for this run: the routine's per-routine override, else the global default. /// Governs both between-set rests and (in flow mode) the between-exercise rest. private var effectiveRest: Int { max(1, doc.restSeconds ?? restSeconds) } /// The next not-yet-resolved exercise after this one, in plan order — the hand-off /// target for flow mode. Nil when this is the last unfinished exercise (→ the run ends). private var nextUnfinishedLogID: String? { let sorted = doc.logs.sorted { $0.order < $1.order } guard let idx = sorted.firstIndex(where: { $0.id == logID }) else { return nil } return sorted[(idx + 1)...].first { log in let status = WorkoutStatus(rawValue: log.status) ?? .notStarted return status == .notStarted || status == .inProgress }?.id } /// The next not-yet-resolved log after this one, in plan order — the flow hand-off /// target's full document, so both its display and library names are reachable. /// Nil when this is the last unfinished exercise. private var nextLog: WorkoutLogDocument? { guard let id = nextUnfinishedLogID else { return nil } return doc.logs.first { $0.id == id } } /// Name of the next not-yet-resolved exercise (the flow hand-off target), or nil when /// this is the last one. Drives the between-exercise rest's "Coming up …" preview. private var nextExerciseName: String? { nextLog?.exerciseName } /// The between-exercise rest — flow mode's terminal page when another exercise follows. /// This is the only rest whose "next" is a *different* exercise, so it's the one that /// previews the next figure and speaks "Coming up …". private var isOnBetweenExerciseRest: Bool { autoAdvance && nextUnfinishedLogID != nil && currentPage == base + cycleCount } /// Which exercise the bottom-half figure should show: the *next* one while resting /// between exercises (so you see what's coming up), otherwise this exercise. This /// is the display name (the headline); `figureLibraryExerciseName` is its figure/ /// info counterpart. private var figureExerciseName: String { if isOnBetweenExerciseRest, let next = nextExerciseName { return next } return log?.exerciseName ?? "" } /// The library name to resolve the bottom-half figure against — the same /// this-exercise-or-next-during-rest choice as `figureExerciseName`, but the /// library name the animation/info actually key on. private var figureLibraryExerciseName: String { if isOnBetweenExerciseRest, let next = nextLog { return libraryIdentity(next) } return libraryIdentity(log) } /// Routine exercise name → library identity, resolved once on appear. Display-only /// fallback for logs minted before the per-log `libraryName` snapshot existed — /// without it a renamed exercise from an older workout loses its figure and guide. @State private var routineLibraryNames: [String: String] = [:] /// A log's library identity: its own snapshot when present, else the routine's /// same-named exercise (legacy logs), else the exercise name itself. private func libraryIdentity(_ log: WorkoutLogDocument?) -> String { guard let log else { return "" } return log.libraryName ?? routineLibraryNames[log.exerciseName] ?? log.exerciseName } /// Fill `routineLibraryNames` from the workout's routine (following the seed /// clone-on-edit redirect), so `libraryIdentity` can repair legacy logs. private func resolveRoutineLibraryNames() { guard let routineID = doc.routineID, let routine = CacheMapper.fetchRoutine(id: sync.currentRoutineID(for: routineID), in: modelContext) else { return } routineLibraryNames = Dictionary( routine.exercisesArray.map { ($0.name, $0.libraryExerciseName) }, uniquingKeysWith: { first, _ in first }) } /// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`. private var base: Int { showsReady ? 1 : 0 } /// Work/rest pages: Work₁, Rest₁, …, Workₙ ⇒ N sets + (N−1) rests = 2N − 1. private var cycleCount: Int { setCount * 2 - 1 } /// Ready (`base`) + cycle (`2N − 1`) + Finish (`1`). private var totalPages: Int { base + cycleCount + 1 } /// The first unfinished set's work page, used to resume an in-progress run. private var resumePage: Int { let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1) return base + completed * 2 } /// Completed-set count a given page implies — the same math `recordProgress` records /// (capped at `setCount − 1`; only an explicit Done marks the final set). private func completedSets(for pageIndex: Int) -> Int { min(max(0, (pageIndex - base + 1) / 2), setCount - 1) } /// Position within the work/rest cycle for the current page — `nil` on the Ready /// and Finish pages (which show no dots). private var currentCycleIndex: Int? { let c = currentPage - base return (0..