Files
workouts/Workouts Watch App/Views/ExerciseProgressView.swift
T
rzen a8d90b638b Anchor auto-advanced phases at the boundary, not at tick time
The rest/timed-work countdown deadline is shared by both devices, but the
page flip crossing it is a local ticker event — and stamping the *next*
phase's anchor at Date() when that event finally ran baked a sleeping
watch's lateness (throttled wrist-down ticker) into its next count-up,
leaving the two devices permanently offset with nothing on the wire to
correct.

Auto-advances now chain the anchor instead: the finished phase's computed
end (passed out of CountdownPhaseView) becomes the next page's PageAnchor,
with the next window derived from it via liveSnapshot(for:at:). A device
arbitrarily late to a boundary shows exactly what the on-time device shows,
and a stack of missed boundaries fast-forwards itself — each chained page
lands already-elapsed and advances on its own next tick, skipping the
start/stop haptics for boundaries that passed while asleep (only a
just-crossed boundary buzzes).

The remoteAnchor* fields are generalized into one PageAnchor (remote frames
and chained auto-advances are the same concept: a page whose timer counts
from a known instant); the phone's Live Activity emit honors it unchanged.
2026-07-09 05:58:32 -04:00

1028 lines
44 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 Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import WatchKit
/// Runs a single exercise as a horizontally-paged flow, mirroring the iPhone's:
///
/// [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). Rep-based **work** phases count
/// *up* (brand purple) and the user swipes on when done; **timed** work phases and
/// **rests** count *down* (rest in gray), ping once per second in the final three
/// seconds, then auto-advance. Sliding past the final set reaches a **Finish** page with
/// **One More** and an auto-firing **Done**. Swiping all the way back to Ready resets the
/// run from scratch.
///
/// A dot row tracks progress with one marker per work set — the active set drawn as a
/// wider dash, with the gap widening at the rest you're currently in.
struct ExerciseProgressView: View {
@Environment(\.dismiss) private var dismiss
/// The shared working workout document owned by the parent. We mutate the matching
/// log in place and ask the parent to forward each change through the bridge —
/// driving the UI from this doc (not the cache) avoids losing rapid edits to the
/// read-after-write race.
@Binding var doc: WorkoutDocument
let logID: String
let onChange: () -> Void
/// Broadcasts the current flow position so a propped-up iPhone can mirror the run 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 mirror reaches it independently off the same wall-clock anchors.
let onLive: (LiveProgress) -> Void
let onLiveEnded: () -> Void
/// The latest live-run frame the *phone* sent for this run, to follow when it drives a
/// transition (ephemeral; nil when the phone isn't driving). Applying it jumps our page
/// without re-broadcasting or re-recording — the originating device owns the durable write.
var incomingFrame: LiveProgress?
/// Rest length between sets, shared with the phone via the same defaults key.
@AppStorage("restSeconds") private var restSeconds: Int = 45
/// Auto-Done countdown on the Finish page — read so the mirror can show the same timer.
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
/// 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 showingCancelConfirm = false
@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. 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
/// Forces the starting page (used only by the DEBUG screenshot host). When set it
/// also suppresses the Ready page so the index is a plain work/rest cycle offset.
private let debugInitialPage: Int?
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, debugInitialPage: Int? = nil) {
self._doc = doc
self.logID = logID
self.onChange = onChange
self.onLive = onLive
self.onLiveEnded = onLiveEnded
self.incomingFrame = incomingFrame
self.debugInitialPage = debugInitialPage
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))
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
// The Ready page always leads the flow (except in the screenshot host). 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 ready = debugInitialPage == nil
_startsResumed = State(initialValue: ready && !notStarted)
_startsCompleted = State(initialValue: ready && log?.status == WorkoutStatus.completed.rawValue)
let base = ready ? 1 : 0
// 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: debugInitialPage ?? (notStarted ? 0 : resume))
}
private var log: WorkoutLogDocument? {
doc.logs.first { $0.id == logID }
}
/// The **Ready?** page always leads the flow, so a resumed run can swipe back to it
/// (which resets the exercise). Suppressed only for the screenshot host, which pins an
/// explicit page. Derived from the immutable `debugInitialPage`, so it stays stable for
/// the life of the screen — the page-index math below depends on it.
private var showsReady: Bool { debugInitialPage == nil }
/// 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)"
}
var body: some View {
if startsCompleted {
CompletedPhaseView()
} else {
flowBody
}
}
private var flowBody: some View {
// The full-screen paged counter flow. Unlike the iPhone's half-and-half split,
// the watch shows no form-guide figure beneath it — the small screen is given
// over entirely to the up/down timer.
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, 2)
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
showingCancelConfirm = true
} label: {
Image(systemName: "xmark")
}
}
}
.confirmationDialog("Cancel Exercise?", isPresented: $showingCancelConfirm, titleVisibility: .visible) {
Button("Cancel Exercise", role: .destructive) { dismiss() }
Button("Continue", role: .cancel) { }
}
.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 phone 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 mirror 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)
}
}
.onChange(of: incomingFrame) { _, frame in
if let frame { applyIncoming(frame) }
}
.onChange(of: log?.currentStateIndex) { _, _ in
repairFromDurable()
}
.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 {
// Not-started opens on the Ready page; the screenshot host pins its own.
finishRestore()
}
}
.onDisappear {
// Leaving the flow (cancel / done / back) — stop the phone mirror.
guard debugInitialPage == nil else { return }
onLiveEnded()
}
}
// 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 mirror.
private func finishRestore() {
didRestorePage = true
if let frame = incomingFrame, frame.logID == logID {
applyIncoming(frame)
} else {
broadcastLive(for: currentPage)
}
}
/// Push the current flow position to a mirroring iPhone. The anchor is stamped *now* — the
/// page just became active — so the mirror's timer lines up with this device's.
private func broadcastLive(for page: Int) {
guard debugInitialPage == nil, let snapshot = liveSnapshot(for: page) else { return }
onLive(snapshot)
}
/// Follow a transition the phone made: jump to the frame's page (matching its set count
/// for a remote One More) without re-broadcasting or re-recording it — the phone 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 application context and is absorbed into `doc` by the parent list. 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 mirror 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 {
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(max(1, restSeconds))))
}
}
/// 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 }
}
@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 {
// Finish page — confirm Done (auto-fires) or add One More.
FinishPhaseView(
isActive: isActive,
onDone: { completeExercise(); dismiss() },
onOneMore: addSet,
anchorEnd: anchorEnd
)
} 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
) { 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.
CountdownPhaseView(
header: "Rest",
tint: .restTimer,
seconds: restSeconds,
isActive: isActive,
anchorStart: anchorStart,
anchorEnd: anchorEnd
) { end in
withAnimation { advance(from: index, phaseEndedAt: end) }
}
}
}
}
// MARK: - Mutations
/// Leave the Ready page for the first work phase, marking the exercise started.
private func start() {
beginExercise()
withAnimation { currentPage = base }
}
/// 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 mirror 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 (and forwards it to the phone), 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; adjustments happen on the phone — no watch adjust UI in v1).
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]
// Skip the write if it's already pristine (e.g. landing on Ready before any set).
knownStateIndex = 0
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() {
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() {
let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
let anyInProgress = statuses.contains { $0 == .inProgress }
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
if allCompleted {
doc.status = WorkoutStatus.completed.rawValue
doc.end = Date()
} else if anyInProgress || !allNotStarted {
doc.status = WorkoutStatus.inProgress.rawValue
doc.end = nil
} else {
doc.status = WorkoutStatus.notStarted.rawValue
doc.end = nil
}
}
// MARK: - Formatting
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: - Haptics
/// The haptic vocabulary used across the flow (set start, countdown ping, rest end, done).
private enum WorkoutHaptic {
case start, tick, stop, success
@MainActor
func play() {
switch self {
case .start: WKInterfaceDevice.current().play(.start)
case .tick: WKInterfaceDevice.current().play(.notification)
case .stop: WKInterfaceDevice.current().play(.stop)
case .success: WKInterfaceDevice.current().play(.success)
}
}
}
// MARK: - Phase Colors
/// watchOS is always dark, so these are static (matching the iPhone's dark-mode tints):
/// brand purple for work, light gray for rest.
private extension Color {
static let workTimer = Color(red: 0.66, green: 0.45, blue: 0.96)
static let restTimer = Color(white: 0.74)
}
// MARK: - Phase Timer Layout
/// The live timer a phase shows, described by its wall-clock anchors so `PhaseTimerLayout` can
/// render it live (system timer text, ticking each second) while active, but fall back to a
/// held "~m:ss" snapshot in the Always-On / wrist-down state — where the system would otherwise
/// throttle the timer text and collapse sub-minute values to a truncated "< 1 min".
private enum PhaseTimer {
/// Count up from a wall-clock anchor (work-set stopwatch).
case countUp(from: Date)
/// Count down across a wall-clock window (rest / timed work set).
case countDown(from: Date, to: Date)
}
/// 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: View {
let header: String
let footer: String
let tint: Color
let timer: PhaseTimer
/// True in the Always-On / wrist-down state (dimmed screen).
@Environment(\.isLuminanceReduced) private var isLuminanceReduced
var body: some View {
VStack(spacing: 4) {
Text(header)
.font(.caption)
.foregroundStyle(.secondary)
timerText
.font(.system(size: 50, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(tint)
.lineLimit(1)
.minimumScaleFactor(0.5)
Text(footer.isEmpty ? " " : footer)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// Live system timer text while active; a held "~m:ss" snapshot in Always-On, so the count
/// stays numeric and on-screen instead of collapsing to the system's truncated "< 1 min".
@ViewBuilder private var timerText: some View {
if isLuminanceReduced {
Text("~\(heldValue)")
} else {
switch timer {
case .countUp(let start):
Text(start, style: .timer)
case .countDown(let start, let end):
Text(timerInterval: start...end, countsDown: true)
}
}
}
/// The most-recent count as of this render, formatted to match the live timer.
private var heldValue: String {
switch timer {
case .countUp(let start):
return Self.clock(Date().timeIntervalSince(start))
case .countDown(_, let end):
return Self.clock(end.timeIntervalSinceNow)
}
}
/// Formats a (clamped-non-negative) interval as `m:ss` / `h:mm:ss`, matching the system
/// timer text's shape.
private static func clock(_ interval: TimeInterval) -> String {
let total = max(0, Int(interval.rounded()))
let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60)
return h > 0 ? String(format: "%d:%02d:%02d", h, m, s)
: String(format: "%d:%02d", m, s)
}
}
// 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 = 5
private let dashWidth: CGFloat = 13
private let markerHeight: CGFloat = 5
private let gap: CGFloat = 5
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 full-width body.
func phaseButtonLabel() -> some View {
self
.font(.system(.headline, design: .rounded, weight: .heavy))
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
}
}
// MARK: - Completed Phase
/// Shown instead of the run flow when the exercise was already completed on open.
private struct CompletedPhaseView: View {
var body: some View {
VStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.green)
Text("Completed")
.font(.system(.title3, design: .rounded, weight: .heavy))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Ready Phase
private struct ReadyPhaseView: View {
let summary: String
let onStart: () -> Void
var body: some View {
VStack(spacing: 8) {
Text("Ready?")
.font(.system(size: 30, weight: .bold, design: .rounded))
if !summary.isEmpty {
Text(summary)
.font(.caption)
.foregroundStyle(.secondary)
}
Button(action: onStart) {
Text("Start")
.phaseButtonLabel()
}
.buttonStyle(.borderedProminent)
.tint(.workTimer)
.buttonBorderShape(.capsule)
.padding(.top, 4)
}
.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
/// on a run-loop `Timer` keeps it advancing in the Always-On / wrist-down state,
/// where that timer is throttled and stops firing.
@State private var startDate = Date()
var body: some View {
PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer,
timer: .countUp(from: startDate))
.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").
///
/// The display is driven by a wall-clock window (so it keeps counting down in the
/// Always-On / wrist-down state), and the haptics + auto-advance are derived from
/// `endDate` rather than a decremented counter — so they stay correct even after the
/// run-loop `Timer` was throttled while the wrist was down.
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
/// 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
@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,
timer: .countDown(from: startDate, to: endDate))
.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 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() }
}
private func tick() {
guard isActive, !didFinish else { return }
// Round up so the final whole second still pings before we reach zero.
let remaining = Int(ceil(endDate.timeIntervalSinceNow))
if remaining <= 0 {
// Time's up — final cue and slide on. If the wrist was down the timer may have
// stalled; this then fires on the first tick once the app gets runtime again.
didFinish = true
// Buzz only for a boundary that just happened — fast-forwarding through
// boundaries that passed while the device slept stays silent.
if Date().timeIntervalSince(endDate) < 3 { WorkoutHaptic.stop.play() }
onFinished(endDate)
} else if remaining <= 3 && remaining < lastPingSecond {
// Once-per-second countdown ping for the final three seconds.
lastPingSecond = remaining
WorkoutHaptic.tick.play()
}
}
}
// MARK: - Finish Phase
/// Terminal page after the last set. **Done** completes the exercise and dismisses — 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 (same Always-On rationale as the rest timer).
/// `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: 8) {
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)
.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()
}
}