Files
workouts/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift
T
rzen df5c77eee1 Let routines rename exercises and repeat them for interval segments
An exercise's name is now a per-routine display name: a new optional
libraryName on ExerciseDocument (snapshotted onto WorkoutLogDocument at
plan time) keeps the link to the bundled library exercise, and every
figure/info/cue lookup resolves libraryName ?? name. Deliberately not
schema-bumped — an older app dropping the key only strands the
figure link, same rationale as activityType. Cache schema bumped to 9
for the new columns.

The picker no longer filters out exercises already in the routine
(an "×N" badge marks them instead), exercise rows gain a leading
Duplicate swipe that clones an entry in place, and the edit sheet gets
a Name field with the library exercise shown read-only above it.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
2026-07-12 18:38:14 -04:00

1651 lines
73 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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`). 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
/// Live watch-forwarded heart rate (see `LiveRunState.heartRate`) — drives the
/// target-HR pill when this run carries a `targetHeartRate`.
@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
init(doc: Binding<WorkoutDocument>, 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) {
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
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)
}
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 next.libraryExerciseName }
return log?.libraryExerciseName ?? ""
}
/// 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 + (N1) 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..<cycleCount).contains(c) ? c : nil
}
/// Work-only progress model for the dot row: one marker per set, a dash on the set
/// being worked, and a doubled gap straddling the rest you're currently in.
private var workDots: WorkPhaseDots.Model? {
guard let c = currentCycleIndex else { return nil }
if c.isMultiple(of: 2) {
let set = c / 2
return .init(setCount: setCount, activeSet: set, restAfterSet: nil, completed: set)
} else {
let set = (c - 1) / 2
return .init(setCount: setCount, activeSet: nil, restAfterSet: set, completed: set + 1)
}
}
private var detail: String {
guard let log else { return "" }
if LoadType(rawValue: log.loadType) == .duration {
return Self.durationLabel(log.durationSeconds)
}
return "\(log.reps) reps"
}
/// Timed exercise: the work phase counts *down* from its duration (and auto-advances),
/// rather than counting *up* until the user swipes on.
private var isDuration: Bool {
guard let log else { return false }
return LoadType(rawValue: log.loadType) == .duration
}
/// Per-set work duration for a timed exercise.
private var workDurationSeconds: Int {
max(1, log?.durationSeconds ?? 1)
}
/// One-line plan summary for the Ready page, e.g. "4 sets × 8 reps".
private var readySummary: String {
let setsText = "\(setCount) set\(setCount == 1 ? "" : "s")"
return detail.isEmpty ? setsText : "\(setsText) × \(detail)"
}
/// The half-and-half split: top/bottom in portrait, side-by-side in landscape
/// (compact vertical size class), so neither half ends up squeezed into a
/// short rotated band. `AnyLayout` preserves view identity across rotation —
/// the paged flow's position and running timers survive the swap.
private var splitLayout: AnyLayout {
verticalSizeClass == .compact
? AnyLayout(HStackLayout(spacing: 0))
: AnyLayout(VStackLayout(spacing: 0))
}
private var isLandscape: Bool { verticalSizeClass == .compact }
/// Scales the landscape exercise-name headline with Dynamic Type.
@ScaledMetric(relativeTo: .largeTitle) private var landscapeNameFontSize: CGFloat = 30
/// Portrait reads the exercise name from the nav bar; landscape's compact bar makes
/// it tiny — too small for a phone propped at arm's length — so there the title is
/// blanked and the figure half spells the name out in large type instead. During a
/// between-exercise rest the figure previews the *next* exercise, so the headline
/// names what's shown.
private var navBarTitle: String {
isLandscape ? "" : (log?.exerciseName ?? "")
}
/// The figure half of the split, with the big-type exercise headline above the
/// figure in landscape. `display` names the headline; `figure` is the library
/// name the animated rig is keyed on — they diverge once an exercise is renamed.
@ViewBuilder
private func figureHalf(display: String, figure: String) -> some View {
if isLandscape {
VStack(spacing: 0) {
Text(display)
.font(.system(size: landscapeNameFontSize, weight: .bold, design: .rounded))
.lineLimit(1)
.minimumScaleFactor(0.5)
.padding(.horizontal)
.padding(.top, 8)
ExerciseFigureSlot(exerciseName: figure)
}
} else {
ExerciseFigureSlot(exerciseName: figure)
}
}
var body: some View {
Group {
if startsCompleted {
// Same half-and-half split as the run flow, with the Completed badge where
// the paged flow would be, so the form-guide figure stays on screen.
splitLayout {
CompletedPhaseView(log: log)
figureHalf(display: log?.exerciseName ?? "", figure: log?.libraryExerciseName ?? "")
}
.navigationTitle(navBarTitle)
.navigationBarTitleDisplayMode(.inline)
} else if startsSkipped {
splitLayout {
SkippedPhaseView()
figureHalf(display: log?.exerciseName ?? "", figure: log?.libraryExerciseName ?? "")
}
.navigationTitle(navBarTitle)
.navigationBarTitleDisplayMode(.inline)
} else {
flowBody
}
}
// Landscape's side-by-side split is already starved for height — hide the tab
// bar there and give the row the full screen. Portrait keeps it.
.toolbarVisibility(verticalSizeClass == .compact ? .hidden : .automatic, for: .tabBar)
}
private var flowBody: some View {
splitLayout {
// Paged flow — top half.
TabView(selection: $currentPage) {
ForEach(0..<totalPages, id: \.self) { index in
page(for: index)
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .bottom) {
if let dots = workDots {
WorkPhaseDots(model: dots)
.padding(.bottom, 8)
}
}
.overlay(alignment: .top) {
heartRatePill
}
// Bottom half: the looping form-guide figure. Shows the *next* exercise while
// resting between exercises (a live preview of what's coming up), otherwise this
// one. The name swap re-loads the figure and carries through the hand-off, so it
// stays put as the next exercise takes over.
figureHalf(display: figureExerciseName, figure: figureLibraryExerciseName)
}
.navigationTitle(navBarTitle)
.navigationBarTitleDisplayMode(.inline)
.onChange(of: currentPage) { oldPage, newPage in
// Ignore page changes until the initial resume settles, so the TabView's
// transient snap-to-0 on first layout can't reset an in-progress run.
guard didRestorePage else { return }
let cause = pageChangeCause
pageChangeCause = nil
switch cause {
case .remote:
// The watch already recorded and owns this transition — just follow it.
break
case .auto:
// Rest / timed-work auto-advance: record forward progress, but don't
// broadcast — the watch reaches this point on its own synchronized timer.
// The chained anchor for the new page was set by `advance`, not cleared.
recordProgress(for: newPage)
case .none:
// A human swipe. Swiping back from the first set to Ready wipes the run
// (only the adjacent 1→0 swipe resets — a stray far jump never does); any
// other page records forward progress. Human transitions are broadcast.
clearAnchor()
if showsReady && newPage == 0 && oldPage == base {
resetExercise()
} else {
recordProgress(for: newPage)
}
broadcastLive(for: newPage)
}
// The Live Activity tracks every settle (all causes), not just human ones — a rest
// that auto-advances or a page the watch drove must still refresh the lock screen.
emitActivity(for: newPage)
}
.onChange(of: incomingFrame) { _, frame in
if let frame { applyIncoming(frame) }
}
.onChange(of: log?.currentStateIndex) { _, _ in
repairFromDurable()
}
.onChange(of: log?.status) { _, raw in
// The log resolved out from under us (the run was completed or ended early on
// another device). Leave the live flow — staying would let the next recorded
// set write a resolved log back to in-progress, resurrecting it through the
// merge. `locallyResolved` keeps our own Done / flow hand-off from re-dismissing.
guard didRestorePage, !locallyResolved,
let status = raw.flatMap(WorkoutStatus.init(rawValue:)),
status == .completed || status == .skipped else { return }
dismiss()
}
.onAppear {
guard !didRestorePage else { return }
if startsResumed {
// Resume on the first unfinished set. A paged TabView can settle on page 0
// on first layout, so re-assert the resume page (now and once more after
// this run loop) before honoring page changes — otherwise that snap would
// land on, and reset at, the Ready page.
jumpToResumePage()
Task { @MainActor in
jumpToResumePage()
finishRestore()
}
} else if enteredViaFlow {
// Auto-advanced into this exercise (flow mode): no Ready tap — begin the
// exercise, then let finishRestore broadcast its first work frame so a
// mirroring device follows across the exercise boundary (a discrete hand-off,
// not a shared-timer auto-advance it could reach on its own).
beginExercise()
announceCue()
finishRestore()
} else {
// Not-started opens on the Ready page.
finishRestore()
}
}
.onDisappear {
// Leaving the flow (back / done) — stop the watch from following and dismiss the
// Live Activity (it lingers briefly on a completed run before auto-dismissing).
// Speech is *not* stopped here: this view is torn down and rebuilt on every
// flow hand-off (`.id(currentLogID)`), so stopping would cut the incoming
// exercise's cue. The run host (`RunFlowView`) silences narration when the
// whole run leaves instead.
onLiveEnded()
onActivityEnded()
}
}
/// Move to the resume page without animation, only if we're not already there
/// (so a re-assert after a TabView snap-to-0 is a no-op in the common case).
private func jumpToResumePage() {
let target = resumePage
guard currentPage != target else { return }
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) { currentPage = target }
}
// MARK: - Live mirror
/// Finish the initial-page restore, then either follow an in-progress remote driver or, if
/// we're the one starting the run, announce our position to the watch.
private func finishRestore() {
didRestorePage = true
if let frame = incomingFrame, frame.logID == logID {
applyIncoming(frame)
} else {
broadcastLive(for: currentPage)
}
// Seed the Live Activity with the opening page (a resumed run starts it here; a
// not-started run sits on Ready, which the controller ignores until the run begins).
emitActivity(for: currentPage)
}
/// Reflect the current flow position onto the Live Activity. Built from the same
/// `liveSnapshot` the watch broadcast uses, but honoring the remote anchors when the page was
/// reached by a watch frame, so the lock-screen countdown lines up with the mirror.
private func emitActivity(for page: Int) {
guard var snapshot = liveSnapshot(for: page) else { return }
if let anchor = pageAnchor, anchor.page == page {
snapshot.phaseStart = anchor.start
snapshot.phaseEnd = anchor.end
}
onActivity(snapshot)
}
/// Push the current flow position to the watch. The anchor is stamped *now* — the page
/// just became active — so the watch's mirror timer lines up with this device's.
private func broadcastLive(for page: Int) {
guard let snapshot = liveSnapshot(for: page) else { return }
onLive(snapshot)
}
/// Follow a transition the watch made: jump to the frame's page (matching its set count
/// for a remote One More) without re-broadcasting or re-recording it — the watch owns the
/// durable write, which arrives separately over the document sync.
private func applyIncoming(_ frame: LiveProgress) {
guard didRestorePage, frame.logID == logID,
frame.isNewer(thanVersion: lastAppliedVersion, start: lastAppliedStart) else { return }
lastAppliedVersion = frame.version
lastAppliedStart = frame.phaseStart
if frame.setCount != setCount { setCount = frame.setCount }
let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
// A followed frame is progress this screen now *knows*, so a durable echo of the
// same transition can't re-trigger the repair jump (a remote reset re-baselines).
knownStateIndex = frame.phase == .ready ? 0 : max(knownStateIndex, completedSets(for: target))
// Anchor the target page's timer to the sender's wall clock, not local now, so the
// displayed countdown matches regardless of how long the frame took to arrive.
pageAnchor = PageAnchor(page: target, start: frame.phaseStart, end: frame.phaseEnd)
guard target != currentPage else { return }
pageChangeCause = .remote
withAnimation { currentPage = target }
}
/// Durable-state safety net for the ephemeral mirror. A peer's swipe frame can be lost
/// (`sendMessage` is reachable-only and the retry can run dry), but its durable write still
/// arrives via the document sync and is absorbed into `doc` by the parent. When the
/// absorbed doc shows sets completed beyond anything this screen has recorded or
/// followed, jump forward to the first unfinished set's work page so the flow can't stay
/// stuck on a stale phase. Forward-only — progress is monotonic, so a same-or-behind doc
/// is our own write echoing back — and only while the run is still in progress.
private func repairFromDurable() {
guard didRestorePage, let log,
log.status == WorkoutStatus.inProgress.rawValue else { return }
let completed = log.currentStateIndex
guard completed > knownStateIndex else { return }
knownStateIndex = completed
if log.sets > setCount { setCount = log.sets }
let target = base + min(max(0, completed), setCount - 1) * 2
guard target > currentPage else { return }
clearAnchor()
pageChangeCause = .remote
withAnimation { currentPage = target }
}
/// Drop the page anchor when a human transition moves us off the anchored page, so the
/// next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor).
private func clearAnchor() {
pageAnchor = nil
}
/// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index.
private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int {
let set = min(max(0, setIndex), max(0, setCount - 1))
switch phase {
case .ready: return showsReady ? 0 : base
case .work: return base + set * 2
case .rest: return base + set * 2 + 1
case .finish: return base + cycleCount
}
}
/// Build the live-run frame for a given page: phase, the set it pertains to, and the
/// wall-clock anchors the watch counts off. Count-down phases (rest, timed work, finish)
/// carry an end anchor; a rep-based work set counts up and leaves it `nil`.
private func liveSnapshot(for page: Int, at now: Date = Date()) -> LiveProgress? {
guard let log else { return nil }
func frame(_ phase: LiveRunPhase, setIndex: Int, end: Date?) -> LiveProgress {
LiveProgress(
workoutID: doc.id,
logID: logID,
exerciseName: log.exerciseName,
phase: phase,
setIndex: setIndex,
setCount: setCount,
detail: detail,
phaseStart: now,
phaseEnd: end,
version: 0
)
}
if showsReady && page == 0 {
return frame(.ready, setIndex: 0, end: nil)
}
let cycleIndex = page - base
if cycleIndex == cycleCount {
// In flow mode with a next exercise, the terminal page is a between-exercise
// rest (mirrored as a `.rest` frame), not the Finish page.
if autoAdvance && nextUnfinishedLogID != nil {
return frame(.rest, setIndex: max(0, setCount - 1),
end: now.addingTimeInterval(Double(effectiveRest)))
}
return frame(.finish, setIndex: max(0, setCount - 1),
end: now.addingTimeInterval(Double(max(1, doneCountdownSeconds))))
} else if cycleIndex.isMultiple(of: 2) {
let set = cycleIndex / 2
let end = isDuration ? now.addingTimeInterval(Double(workDurationSeconds)) : nil
return frame(.work, setIndex: set, end: end)
} else {
let set = (cycleIndex - 1) / 2 // the rest follows this set
return frame(.rest, setIndex: set, end: now.addingTimeInterval(Double(effectiveRest)))
}
}
@ViewBuilder
private func page(for index: Int) -> some View {
let isActive = index == currentPage
// Only the anchored page (remote frame or chained auto-advance) carries anchors;
// every other page (reached by a human transition) counts off its own `now`.
let anchorStart = pageAnchor?.page == index ? pageAnchor?.start : nil
let anchorEnd = pageAnchor?.page == index ? pageAnchor?.end : nil
if showsReady && index == 0 {
ReadyPhaseView(summary: readySummary, onStart: start)
} else {
let cycleIndex = index - base
if cycleIndex == cycleCount {
if autoAdvance, nextUnfinishedLogID != nil {
// Flow mode: the terminal page is a between-exercise rest that
// completes this exercise and hands off to the next when it ends.
VStack(spacing: 0) {
CountdownPhaseView(
header: "Rest",
tint: .restTimer,
seconds: effectiveRest,
isActive: isActive,
anchorStart: anchorStart,
anchorEnd: anchorEnd,
comingUpName: nextExerciseName,
onActivate: announceComingUp,
onCountdownBeat: announceCountdownBeat
) { _ in
completeExercise()
// Re-resolve at fire time — the next exercise may have been
// finished elsewhere while we rested. None left ⇒ end the run.
// No hand-off host (the live-mirror cover) ⇒ close instead of
// freezing on a spent countdown; a follower re-presents on the
// driver's next-exercise frame.
if let next = nextUnfinishedLogID, let onAdvance {
onAdvance(next)
} else {
dismiss()
}
}
adjustPill
}
} else {
// Finish page — confirm Done (auto-fires) or add One More.
VStack(spacing: 0) {
FinishPhaseView(
isActive: isActive,
onDone: { completeExercise(); dismiss() },
onOneMore: addSet,
anchorEnd: anchorEnd
)
adjustPill
}
}
} else if cycleIndex.isMultiple(of: 2) {
let setNumber = cycleIndex / 2 + 1
if isDuration {
// Timed work set — count down from the planned duration, then
// auto-advance (and buzz) the same way a rest does.
CountdownPhaseView(
header: "\(setNumber) of \(setCount)",
tint: .workTimer,
seconds: workDurationSeconds,
isActive: isActive,
anchorStart: anchorStart,
anchorEnd: anchorEnd,
onCountdownBeat: announceWorkEndBeat
) { end in
withAnimation { advance(from: index, phaseEndedAt: end) }
}
} else {
// Rep-based work set — count up; the user swipes left when done.
WorkPhaseView(
setNumber: setNumber,
totalSets: setCount,
detail: detail,
isActive: isActive,
anchorStart: anchorStart
)
}
} else {
// Rest phase. Auto-advances to the next work page when the timer hits zero.
VStack(spacing: 0) {
CountdownPhaseView(
header: "Rest",
tint: .restTimer,
seconds: effectiveRest,
isActive: isActive,
anchorStart: anchorStart,
anchorEnd: anchorEnd,
onCountdownBeat: announceCountdownBeat
) { end in
withAnimation { advance(from: index, phaseEndedAt: end) }
}
adjustPill
}
}
}
}
// MARK: - Live heart rate vs. target
/// Live heart rate against this run's target (cardio routines only): the bpm the
/// watch is streaming, tinted by band, with a cue to ease off (arrow down) or push
/// harder (arrow up) — how you dial in a treadmill incline from a propped phone.
/// Renders nothing unless the run carries a target *and* a fresh sample exists, so
/// it has zero footprint on strength runs or when the watch isn't active.
@ViewBuilder
private var heartRatePill: some View {
if let target = doc.targetHeartRate, let bpm = liveRun.heartRate {
let band = HeartRateBand(bpm: bpm, target: target)
HStack(spacing: 6) {
Image(systemName: "heart.fill")
Text("\(Int(bpm.rounded()))")
.monospacedDigit()
Image(systemName: band.cueSymbol)
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(band.tint)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(.quaternary.opacity(0.5), in: Capsule())
.padding(.top, 6)
.animation(.easeInOut(duration: 0.3), value: band)
}
}
// MARK: - Adjust the last set's entry
/// The just-completed set's recorded entry, shown on the rest and finish pages
/// as a tappable pill — the log-by-default record with a correct-by-exception
/// affordance. Dead time by design: it never blocks the countdown or auto-advance.
@ViewBuilder
private var adjustPill: some View {
if let entry = log?.setEntries?.last {
Button {
showingAdjust = true
} label: {
HStack(spacing: 6) {
Text(SetEntryFormat.summary(entry, weightUnit: weightUnit))
Image(systemName: "pencil")
.font(.caption)
}
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(.quaternary.opacity(0.5), in: Capsule())
}
.buttonStyle(.plain)
.padding(.bottom, 32) // clear the phase-dot row overlaid at the bottom
.sheet(isPresented: $showingAdjust) { adjustSheet }
}
}
@ViewBuilder
private var adjustSheet: some View {
if let log, let entry = log.setEntries?.last {
SetEntryAdjustSheet(
entry: entry,
setNumber: log.setEntries?.count ?? 1,
loadType: LoadType(rawValue: log.loadType) ?? .weight,
weightUnit: weightUnit,
onChange: updateLastEntry
)
.presentationDetents([.height(300)])
.presentationDragIndicator(.visible)
}
}
/// Write an adjusted value back onto the last recorded entry and persist.
private func updateLastEntry(_ entry: SetEntry) {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }),
let last = doc.logs[i].setEntries?.indices.last else { return }
doc.logs[i].setEntries?[last] = entry
doc.logs[i].touch()
doc.updatedAt = Date()
onChange()
}
// MARK: - Mutations
/// Leave the Ready page for the first work phase, marking the exercise started.
private func start() {
beginExercise()
announceCue()
withAnimation { currentPage = base }
}
/// True when narration is on and the chosen style speaks the concise coming-up/countdown
/// cues. Also requires a wired announcer (nil in the screenshot host).
private var speaksCues: Bool {
speakExerciseCues && spokenCueStyle.speaksCues && speechAnnouncer != nil
}
/// Speak the exercise's setup/form cues when it starts, if the user opted into a style
/// that reads them. Silent when the announcer isn't wired (screenshot host) or the
/// exercise has no bundled info.
private func announceCue() {
guard speakExerciseCues, spokenCueStyle.readsInstructions,
let announcer = speechAnnouncer, let log else { return }
guard let info = ExerciseInfoLibrary.info(for: log.libraryExerciseName) else { return }
announcer.speak(info.spokenScript(name: log.exerciseName, brief: true))
}
/// Announce the exercise coming up after a between-exercise rest ("Coming up, Pull Ups").
/// Spoken as the rest begins, so it lands well before the next exercise starts.
private func announceComingUp() {
guard speaksCues, let announcer = speechAnnouncer, let next = nextExerciseName else { return }
announcer.speak("Coming up. \(next).")
}
/// Count into the next work phase, one word per second synced to the digits the countdown
/// displays: the upcoming exercise's name as it shows 6 — with the 5s and 4s beats left
/// silent so even a long name finishes — then "in three" (3) → "two" → "one" → "Go!" as
/// the display reaches zero. Every beat but "Go" holds the audio session so background
/// music doesn't pulse between them; "Go" releases it, and (like any cue) plays to
/// completion even if the rest ends mid-word. `remaining` is the displayed digit.
private func announceCountdownBeat(_ remaining: Int) {
guard speaksCues, let announcer = speechAnnouncer else { return }
switch remaining {
case 6:
// The exercise about to be worked: the next one across a between-exercise rest,
// otherwise this one. Skipped when unknown (never blocks the count that follows).
let name = figureExerciseName
if !name.isEmpty { announcer.speak(name + ".", holdSession: true) }
case 5, 4: break // buffer beats — let the exercise name play out before the count
case 3: announcer.speak("In three.", holdSession: true)
case 2: announcer.speak("Two.", holdSession: true)
case 1: announcer.speak("One.", holdSession: true)
default: announcer.speak("Go!") // display at zero — release the duck for the work phase
}
}
/// Count a timed work set out, mirroring the count-in: "<Exercise> ends." as the display
/// shows 6 (5 and 4 are its silent buffer), then "in three" → "two" → "one" — and silence
/// at zero, where the rest phase's own buzz marks the boundary. "One" is the last word,
/// so it releases the audio duck instead of holding it.
private func announceWorkEndBeat(_ remaining: Int) {
guard speaksCues, let announcer = speechAnnouncer else { return }
switch remaining {
case 6:
if let name = log?.exerciseName, !name.isEmpty {
announcer.speak("\(name) ends.", holdSession: true)
}
case 3: announcer.speak("In three.", holdSession: true)
case 2: announcer.speak("Two.", holdSession: true)
case 1: announcer.speak("One.")
default: break // 54 are the name's buffer beats; 0 stays silent
}
}
/// Programmatically move one page right when a countdown phase ends, guarding against
/// overrun if the user swiped away in the meantime. Tagged `.auto` so the page observer
/// records progress but doesn't broadcast it (the watch auto-advances too).
///
/// `phaseEndedAt` is the finished phase's *computed* end — the next phase began then,
/// not when this tick finally got runtime. Anchoring the next page there means a device
/// that slept through the boundary (throttled wrist-down ticker) shows the same timer
/// as one that crossed it on time — and a stack of missed boundaries fast-forwards
/// itself, since each chained countdown lands already-elapsed and advances on its own
/// next tick.
private func advance(from index: Int, phaseEndedAt end: Date) {
guard currentPage == index, index + 1 < totalPages else { return }
pageAnchor = PageAnchor(page: index + 1, start: end,
end: liveSnapshot(for: index + 1, at: end)?.phaseEnd)
pageChangeCause = .auto
currentPage = index + 1
}
/// Flip a not-yet-started exercise to in-progress the moment the user taps Start.
private func beginExercise() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
guard doc.logs[i].status == WorkoutStatus.notStarted.rawValue else { return }
doc.logs[i].transition(to: .inProgress)
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
}
/// Append a bonus set from the Finish page: mark every prior set done, grow the
/// plan, and slide forward into the bonus set's work phase.
private func addSet() {
let newCount = setCount + 1
knownStateIndex = max(knownStateIndex, newCount - 1)
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
doc.logs[i].sets = newCount
doc.logs[i].currentStateIndex = newCount - 1 // every prior set is now complete
// The old final set just became a completed *prior* set — record its entry.
// The bonus set's own entry lands when it completes, not now.
doc.logs[i].fillSetEntries(upTo: newCount - 1, at: Date())
doc.logs[i].transition(to: .inProgress)
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
}
withAnimation {
setCount = newCount
currentPage += 1
}
}
/// Map a page to completed-set count and record forward progress.
///
/// Paging tops out at `setCount 1` completed sets — the final set is marked done
/// only by an explicit **Done** (`completeExercise`). Progress is **monotonic**:
/// completing a work phase advances the count, but swiping back — or a transient
/// TabView snap to page 0 — never un-counts a set.
private func recordProgress(for pageIndex: Int) {
if showsReady && pageIndex == 0 { return } // Ready page records nothing
let reached = completedSets(for: pageIndex)
knownStateIndex = max(knownStateIndex, reached)
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
guard reached > doc.logs[i].currentStateIndex else { return }
doc.logs[i].currentStateIndex = reached
// Log-by-default: each newly-completed set gets a plan-filled entry
// (append-only — swiping back never removes one; adjust corrects the last).
doc.logs[i].fillSetEntries(upTo: reached, at: Date())
doc.logs[i].transition(to: .inProgress)
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
}
/// Swiping back to the **Ready?** page starts the exercise over from scratch.
private func resetExercise() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
let log = doc.logs[i]
knownStateIndex = 0
// Skip the write if it's already pristine (e.g. landing on Ready before any set).
guard log.currentStateIndex != 0
|| log.status != WorkoutStatus.notStarted.rawValue else { return }
doc.logs[i].currentStateIndex = 0
doc.logs[i].transition(to: .notStarted)
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
}
private func completeExercise() {
locallyResolved = true
knownStateIndex = max(knownStateIndex, setCount)
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].currentStateIndex = setCount
doc.logs[i].transition(to: .completed)
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
}
private func recomputeWorkoutStatus() {
doc.recomputeStatusFromLogs()
}
// MARK: - Formatting
nonisolated static func durationLabel(_ seconds: Int) -> String {
let mins = seconds / 60
let secs = seconds % 60
if mins > 0 && secs > 0 { return "\(mins)m \(secs)s" }
if mins > 0 { return "\(mins) min" }
return "\(secs) sec"
}
}
// MARK: - Set entry formatting
/// Renders recorded `SetEntry` actuals for display (the pill, the Completed page).
/// Which fields an entry carries follows the log's `LoadType`, so formatting keys
/// off the populated fields rather than re-deriving the type.
enum SetEntryFormat {
/// One entry: "10 reps · 45 lb", "10 reps", or "45 sec".
static func summary(_ entry: SetEntry, weightUnit: WeightUnit) -> String {
var parts: [String] = []
if let reps = entry.reps { parts.append("\(reps) reps") }
if let weight = entry.weight { parts.append(weightUnit.format(weight)) }
if let seconds = entry.seconds { parts.append(ExerciseProgressView.durationLabel(seconds)) }
return parts.joined(separator: " · ")
}
/// All of a log's entries on one line: "4 × 10 reps @ 45 lb" when every set
/// matched, "10 · 8 · 6 reps @ 45 lb" when reps vary but the weight is uniform,
/// a per-set "10 × 45 lb · 8 × 42.5 lb" list when the weight varies too,
/// "3 × 45 sec" / "45 · 45 · 30 sec" for durations, and the same for bodyweight.
static func actualsLine(_ entries: [SetEntry], weightUnit: WeightUnit) -> String {
guard !entries.isEmpty else { return "" }
if entries.contains(where: { $0.seconds != nil }) {
return collapsedList(entries.map { $0.seconds ?? 0 }) + " sec"
}
let repsList = collapsedList(entries.map { $0.reps ?? 0 })
let weights = entries.compactMap(\.weight)
guard !weights.isEmpty else { return repsList + " reps" }
if Set(weights).count == 1 {
return "\(repsList) reps @ \(weightUnit.format(weights[0]))"
}
return entries.map { entry in
guard let weight = entry.weight else { return "\(entry.reps ?? 0)" }
return "\(entry.reps ?? 0) × \(weightUnit.format(weight))"
}.joined(separator: " · ")
}
/// A per-set value list, collapsed to "N × v" when every set matches (so four
/// tens read as "4 × 10", not "10 · 10 · 10 · 10") and left as a "10 · 8 · 6"
/// list otherwise. A single set is just its value (never "1 × v").
private static func collapsedList(_ values: [Int]) -> String {
guard let first = values.first else { return "" }
if values.count > 1 && values.allSatisfy({ $0 == first }) {
return "\(values.count) × \(first)"
}
return values.map(String.init).joined(separator: " · ")
}
}
// MARK: - Set entry adjust sheet
/// Compact steppers for correcting the just-completed set's entry — reps and
/// weight for a weighted set, reps alone for bodyweight, seconds for a timed
/// hold. Changes apply live (via `onChange`), so a swipe-down needs no confirm.
private struct SetEntryAdjustSheet: View {
let setNumber: Int
let loadType: LoadType
let weightUnit: WeightUnit
let onChange: (SetEntry) -> Void
@State private var entry: SetEntry
init(entry: SetEntry, setNumber: Int, loadType: LoadType, weightUnit: WeightUnit,
onChange: @escaping (SetEntry) -> Void) {
_entry = State(initialValue: entry)
self.setNumber = setNumber
self.loadType = loadType
self.weightUnit = weightUnit
self.onChange = onChange
}
/// Plate-math step in the *display* unit (the stored value stays unit-less).
private var weightStep: Double { weightUnit == .kg ? 1.25 : 2.5 }
var body: some View {
VStack(spacing: 24) {
Text("Set \(setNumber)")
.font(.headline)
.foregroundStyle(.secondary)
switch loadType {
case .weight:
repsStepper
weightStepper
case .none:
repsStepper
case .duration:
secondsStepper
}
}
.padding(28)
.frame(maxHeight: .infinity, alignment: .top)
.onChange(of: entry) { _, updated in onChange(updated) }
}
private var repsStepper: some View {
Stepper(value: Binding(get: { entry.reps ?? 0 }, set: { entry.reps = $0 }),
in: 0...200) {
Text("\(entry.reps ?? 0) reps")
.font(.title3.weight(.semibold))
.monospacedDigit()
}
}
private var weightStepper: some View {
Stepper(value: Binding(get: { entry.weight ?? 0 }, set: { entry.weight = $0 }),
in: 0...2000, step: weightStep) {
Text(weightUnit.format(entry.weight ?? 0))
.font(.title3.weight(.semibold))
.monospacedDigit()
}
}
private var secondsStepper: some View {
Stepper(value: Binding(get: { entry.seconds ?? 0 }, set: { entry.seconds = $0 }),
in: 0...3600, step: 5) {
Text(ExerciseProgressView.durationLabel(entry.seconds ?? 0))
.font(.title3.weight(.semibold))
.monospacedDigit()
}
}
}
// MARK: - Haptics
/// Maps the watch flow's haptic vocabulary onto UIKit feedback generators so the iPhone
/// flow buzzes at the same beats (set start, countdown ping, rest end, done).
private enum WorkoutHaptic {
case start, tick, stop, success
@MainActor
func play() {
switch self {
case .start:
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
case .tick:
UIImpactFeedbackGenerator(style: .light).impactOccurred()
case .stop:
UIImpactFeedbackGenerator(style: .rigid).impactOccurred()
case .success:
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
}
}
// MARK: - Heart-rate band presentation
private extension HeartRateBand {
/// The correction cue: below target → push harder (up), above → ease off (down).
var cueSymbol: String {
switch self {
case .low: "arrow.up"
case .inRange: "checkmark"
case .high: "arrow.down"
}
}
var tint: Color {
switch self {
case .low: .blue
case .inRange: .green
case .high: .orange
}
}
}
// MARK: - Phase Colors
private extension Color {
/// Count-up work tint — brand purple, brightened in dark mode for contrast on
/// black and deepened in light mode for contrast on white.
static let workTimer = Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.66, green: 0.45, blue: 0.96, alpha: 1)
: UIColor(red: 0.45, green: 0.18, blue: 0.78, alpha: 1)
})
/// Count-down rest tint — a light gray that deepens in light mode so it stays
/// legible on a white background.
static let restTimer = Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(white: 0.74, alpha: 1)
: UIColor(white: 0.52, alpha: 1)
})
}
// MARK: - Phase Timer Layout
/// Shared skeleton for the work and rest pages so their timers use an identical font
/// and land at exactly the same spot: a header line, the big timer, then a footer line.
/// The footer reserves its height even when empty, keeping the timer centered the same
/// way on both pages.
private struct PhaseTimerLayout<Content: View>: View {
let header: String
let footer: String
let tint: Color
/// When set, the header row becomes a two-line "Coming up" / exercise-name preview,
/// with the name in larger type — used by the between-exercise rest.
var comingUpName: String? = nil
@ViewBuilder var timer: Content
/// Scales the big timer digits with Dynamic Type (falls back to `.minimumScaleFactor`
/// so the largest sizes never overflow the top half).
@ScaledMetric(relativeTo: .largeTitle) private var timerFontSize: CGFloat = 108
/// Scales the "coming up" exercise name — large, but well under the timer.
@ScaledMetric(relativeTo: .largeTitle) private var comingUpFontSize: CGFloat = 34
var body: some View {
VStack(spacing: 10) {
if let comingUpName {
VStack(spacing: 2) {
Text("Coming up")
.font(.title3)
.foregroundStyle(.secondary)
Text(comingUpName)
.font(.system(size: comingUpFontSize, weight: .bold, design: .rounded))
.lineLimit(1)
.minimumScaleFactor(0.5)
.padding(.horizontal)
}
} else {
Text(header)
.font(.title3)
.foregroundStyle(.secondary)
}
timer
.font(.system(size: timerFontSize, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(tint)
.lineLimit(1)
.minimumScaleFactor(0.5)
Text(footer.isEmpty ? " " : footer)
.font(.title3)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Work Phase Dots
/// Progress row with one marker per work set. The set being worked is drawn as a wider
/// dash; during a rest every marker is a plain dot and the gap straddling that rest
/// grows to about double, hinting at the pause between the set that just ended and the
/// next one. Completed sets are full strength, upcoming ones dimmed.
private struct WorkPhaseDots: View {
struct Model: Equatable {
let setCount: Int
/// The set currently being worked — drawn as a dash. `nil` during a rest.
let activeSet: Int?
/// The set just completed; the gap *after* its dot doubles. `nil` during work.
let restAfterSet: Int?
/// How many sets are fully done (for dimming the upcoming ones).
let completed: Int
}
let model: Model
// Geometry — tune freely.
private let dotWidth: CGFloat = 8
private let dashWidth: CGFloat = 20
private let markerHeight: CGFloat = 8
private let gap: CGFloat = 8
private var restGap: CGFloat { gap * 2 }
var body: some View {
HStack(spacing: 0) {
ForEach(0..<model.setCount, id: \.self) { i in
marker(for: i)
if i < model.setCount - 1 {
Color.clear.frame(width: gapWidth(after: i), height: markerHeight)
}
}
}
.animation(.easeInOut(duration: 0.3), value: model)
}
private func marker(for i: Int) -> some View {
let isActive = model.activeSet == i
let isDone = i < model.completed
return Capsule()
.fill(Color.workTimer)
.frame(width: isActive ? dashWidth : dotWidth, height: markerHeight)
.opacity(isActive || isDone ? 1 : 0.45)
}
private func gapWidth(after i: Int) -> CGFloat {
model.restAfterSet == i ? restGap : gap
}
}
// MARK: - Phase Button Styling
private extension View {
/// Chunky, rounded, heavy treatment shared by the Start / Done / One More buttons:
/// a plump label (echoing the counter digits) over a taller full-width body.
func phaseButtonLabel() -> some View {
self
.font(.system(.title2, design: .rounded, weight: .heavy))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
}
// MARK: - Completed Phase
/// Shown instead of the run flow when the exercise was already completed on open:
/// the badge, then when it was completed, what was done, and how long it took
/// (the timing lines appear only on logs that carry the timestamps).
private struct CompletedPhaseView: View {
let log: WorkoutLogDocument?
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
/// Scales the completion badge with Dynamic Type.
@ScaledMetric(relativeTo: .largeTitle) private var badgeSize: CGFloat = 96
/// "4 sets × 12 reps" / "3 sets × 45 sec".
private var planSummary: String {
guard let log else { return "" }
let setsText = "\(log.sets) set\(log.sets == 1 ? "" : "s")"
if LoadType(rawValue: log.loadType) == .duration {
return "\(setsText) × \(ExerciseProgressView.durationLabel(log.durationSeconds))"
}
return "\(setsText) × \(log.reps) reps"
}
/// Start-to-done wall time, when both timestamps were recorded.
private var elapsed: String? {
guard let log, let start = log.startedAt, let end = log.completedAt else { return nil }
let seconds = Int(end.timeIntervalSince(start))
guard seconds > 0 else { return nil }
return ExerciseProgressView.durationLabel(seconds)
}
var body: some View {
VStack(spacing: 14) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: badgeSize, weight: .bold))
.foregroundStyle(Color.accentColor)
Text("Completed")
.font(.system(.title, design: .rounded, weight: .heavy))
.foregroundStyle(Color.accentColor)
if let log {
VStack(spacing: 4) {
if let completedAt = log.completedAt {
Text(completedAt.formattedRelativeDateTime())
}
// Recorded actuals ("10 · 8 · 6 reps @ 45 lb") beat the plan
// summary; legacy logs without entries keep showing the plan.
if let entries = log.setEntries, !entries.isEmpty {
Text(SetEntryFormat.actualsLine(entries, weightUnit: weightUnit))
} else {
Text(planSummary)
}
if let elapsed {
Text("in \(elapsed)")
}
}
.font(.callout)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Skipped Phase
/// Shown instead of the run flow when the exercise was skipped (the workout was
/// ended early with this one unfinished). Same badge treatment as Completed,
/// in gray, matching the list row's skipped icon.
private struct SkippedPhaseView: View {
/// Scales the skipped badge with Dynamic Type.
@ScaledMetric(relativeTo: .largeTitle) private var badgeSize: CGFloat = 96
var body: some View {
VStack(spacing: 14) {
Image(systemName: "wrongwaysign")
.font(.system(size: badgeSize, weight: .bold))
.foregroundStyle(.gray)
Text("Skipped")
.font(.system(.title, design: .rounded, weight: .heavy))
.foregroundStyle(.gray)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Ready Phase
private struct ReadyPhaseView: View {
let summary: String
let onStart: () -> Void
/// Scales the "Ready?" headline with Dynamic Type.
@ScaledMetric(relativeTo: .largeTitle) private var readyFontSize: CGFloat = 44
var body: some View {
VStack(spacing: 14) {
Text("Ready?")
.font(.system(size: readyFontSize, weight: .bold, design: .rounded))
.lineLimit(1)
.minimumScaleFactor(0.5)
if !summary.isEmpty {
Text(summary)
.font(.title3)
.foregroundStyle(.secondary)
}
Button(action: onStart) {
Text("Start")
.phaseButtonLabel()
}
.buttonStyle(.borderedProminent)
.tint(.workTimer)
.buttonBorderShape(.capsule)
.padding(.top, 8)
.padding(.horizontal, 40)
}
.padding(.horizontal)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Work Phase
private struct WorkPhaseView: View {
let setNumber: Int
let totalSets: Int
let detail: String
let isActive: Bool
/// When the page was reached by a remote frame, anchor the count-up to the sender's
/// wall clock instead of local `now`, so the mirror lines up regardless of delivery lag.
var anchorStart: Date? = nil
/// Wall-clock anchor for the count-up stopwatch. Driving the display from a fixed
/// start date (rendered by SwiftUI's timer text) instead of incrementing a counter
/// keeps it advancing without a run-loop timer.
@State private var startDate = Date()
var body: some View {
PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) {
Text(startDate, style: .timer)
}
.onAppear { if isActive { restart(haptic: true) } }
.onChange(of: isActive) { _, active in if active { restart(haptic: true) } }
// A later frame for this same page re-anchors the timer without re-buzzing.
.onChange(of: anchorStart) { _, _ in if isActive { restart(haptic: false) } }
}
private func restart(haptic: Bool) {
startDate = anchorStart ?? Date()
if haptic { WorkoutHaptic.start.play() }
}
}
// MARK: - Countdown Phase
/// A count-down phase used for both rests and timed work sets: counts down from
/// `seconds`, pings once per second in the final three, then buzzes and auto-advances at
/// zero. The header/tint distinguish the two uses (purple "N of M" work vs. gray "Rest").
private struct CountdownPhaseView: View {
let header: String
var footer: String = ""
let tint: Color
let seconds: Int
let isActive: Bool
/// When the page was reached by a remote frame, anchor the countdown window to the
/// sender's wall clock (`anchorStart`…`anchorEnd`) instead of local `now`…`now+seconds`,
/// so the remaining time — and the auto-advance at zero — line up across both devices.
var anchorStart: Date? = nil
var anchorEnd: Date? = nil
/// When set, the header becomes a "Coming up / <name>" preview (between-exercise rest).
var comingUpName: String? = nil
/// Fired once when the countdown genuinely begins (not a slept-through catch-up) — the
/// spoken "Coming up …" cue hangs off this.
var onActivate: (() -> Void)? = nil
/// Fired once per second through the final beats — the digit the countdown display
/// currently shows (6 for the exercise name, 54 as its buffer, then 3, 2, 1, and 0 as
/// the display reaches zero) — so a spoken count ("Torso Twist… in three… two… one…
/// Go!") names the number on screen, in step with the haptics. Nil skips it.
var onCountdownBeat: ((Int) -> Void)? = nil
/// Invoked once the countdown reaches zero (auto-advance to the next page), passing the
/// phase's *computed* end so the next phase can anchor at the boundary itself — not at
/// whenever this tick got runtime (a device asleep at the boundary ticks late).
let onFinished: (Date) -> Void
/// Wall-clock window for the countdown. SwiftUI renders the remaining time from this
/// range, and the haptics + auto-advance below are derived from `endDate` rather than
/// a decremented counter so they stay correct even if a tick is delayed.
@State private var startDate = Date()
@State private var endDate = Date()
/// Lowest remaining-second we've already pinged, so a burst of catch-up ticks doesn't
/// double-buzz.
@State private var lastPingSecond = Int.max
/// Guards the auto-advance so it fires exactly once even if ticks pile up.
@State private var didFinish = false
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
var body: some View {
PhaseTimerLayout(header: header, footer: footer, tint: tint, comingUpName: comingUpName) {
Text(timerInterval: startDate...endDate, countsDown: true)
}
.onAppear { if isActive { start(haptic: true) } }
.onChange(of: isActive) { _, active in if active { start(haptic: true) } }
// A later frame for this same page re-anchors the window without re-buzzing.
.onChange(of: anchorEnd) { _, _ in if isActive { start(haptic: false) } }
.onReceive(ticker) { _ in tick() }
}
private func start(haptic: Bool) {
startDate = anchorStart ?? Date()
endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds)))
lastPingSecond = Int.max
didFinish = false
// No buzz — or spoken cue — for a chained catch-up page whose whole window already
// elapsed while the device slept; it advances again on its next tick.
if haptic, endDate > Date() {
WorkoutHaptic.start.play()
onActivate?()
}
}
private func tick() {
guard isActive, !didFinish else { return }
let timeLeft = endDate.timeIntervalSinceNow
if timeLeft <= 0 {
didFinish = true
// Buzz the boundary only when it just happened — fast-forwarding through
// boundaries that passed while the device slept stays silent.
if Date().timeIntervalSince(endDate) < 3 {
WorkoutHaptic.stop.play()
// A missed final tick means the 0 beat ("Go!") never fired — deliver it here.
if lastPingSecond > 0 { onCountdownBeat?(0) }
}
onFinished(endDate)
} else {
// Floor, not ceil: `Text(timerInterval:)` displays the floored seconds (it reads
// 0:00 through the whole last second), so keying the beats to the same floored
// value makes each spoken word name the digit on screen. Beat 0 lands as the
// display hits zero, up to a second before the actual phase boundary.
let displayed = Int(timeLeft)
if displayed <= 6 && displayed < lastPingSecond {
lastPingSecond = displayed
if displayed <= 2 { WorkoutHaptic.tick.play() }
onCountdownBeat?(displayed)
}
}
}
}
// MARK: - Finish Phase
/// Terminal page after the last set. **Done** completes the exercise — and fires
/// automatically after a configurable countdown so the user doesn't have to tap with
/// sweaty hands. **One More** appends a bonus set instead.
private struct FinishPhaseView: View {
let isActive: Bool
let onDone: () -> Void
let onOneMore: () -> Void
/// When the page was reached by a remote frame, anchor the auto-Done deadline to the
/// sender's wall clock instead of local `now + countdown`, so both devices fire together.
var anchorEnd: Date? = nil
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
/// Wall-clock deadline for the auto-Done. `remaining` is what the Done button shows.
@State private var endDate = Date()
@State private var remaining = 0
/// Fires the auto-Done exactly once, and latches off while the page isn't active.
@State private var didFire = false
private let ticker = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()
var body: some View {
VStack(spacing: 14) {
Button(action: fire) {
Label(remaining > 0 ? "Done · \(remaining)" : "Done", systemImage: "checkmark")
.phaseButtonLabel()
}
.buttonStyle(.borderedProminent)
.tint(Color.workTimer)
.buttonBorderShape(.capsule)
Button(action: onOneMore) {
Label("One More", systemImage: "plus")
.phaseButtonLabel()
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
}
.padding(.horizontal, 40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { if isActive { start() } }
.onChange(of: isActive) { _, active in active ? start() : (didFire = true) }
.onChange(of: anchorEnd) { _, _ in if isActive { start() } }
.onReceive(ticker) { _ in tick() }
}
private func start() {
endDate = anchorEnd ?? Date().addingTimeInterval(Double(max(1, doneCountdownSeconds)))
remaining = max(0, Int(ceil(endDate.timeIntervalSinceNow)))
didFire = false
}
private func tick() {
guard isActive, !didFire else { return }
let r = Int(ceil(endDate.timeIntervalSinceNow))
remaining = max(0, r)
if r <= 0 { fire() }
}
private func fire() {
guard !didFire else { return }
didFire = true
WorkoutHaptic.success.play()
onDone()
}
}