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.
1287 lines
54 KiB
Swift
1287 lines
54 KiB
Swift
//
|
||
// 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
|
||
|
||
/// 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
|
||
|
||
/// 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?
|
||
|
||
/// Rest length between sets, shared with the watch via the same defaults key.
|
||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||
|
||
/// 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
|
||
|
||
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) {
|
||
self._doc = doc
|
||
self.logID = logID
|
||
self.onChange = onChange
|
||
self.onLive = onLive
|
||
self.onLiveEnded = onLiveEnded
|
||
self.onActivity = onActivity
|
||
self.onActivityEnded = onActivityEnded
|
||
self.incomingFrame = incomingFrame
|
||
|
||
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)
|
||
|
||
let base = 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 always leads the flow, so a resumed run can swipe back to it
|
||
/// (which resets the exercise). (The watch additionally suppresses it for its
|
||
/// screenshot host; the iPhone has no such host, so it's always shown.)
|
||
private var showsReady: Bool { true }
|
||
|
||
/// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
|
||
private var base: Int { showsReady ? 1 : 0 }
|
||
|
||
/// Work/rest pages: Work₁, Rest₁, …, Workₙ ⇒ N sets + (N−1) rests = 2N − 1.
|
||
private var cycleCount: Int { setCount * 2 - 1 }
|
||
|
||
/// Ready (`base`) + cycle (`2N − 1`) + Finish (`1`).
|
||
private var totalPages: Int { base + cycleCount + 1 }
|
||
|
||
/// The first unfinished set's work page, used to resume an in-progress run.
|
||
private var resumePage: Int {
|
||
let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1)
|
||
return base + completed * 2
|
||
}
|
||
|
||
/// Completed-set count a given page implies — the same math `recordProgress` records
|
||
/// (capped at `setCount − 1`; only an explicit Done marks the final set).
|
||
private func completedSets(for pageIndex: Int) -> Int {
|
||
min(max(0, (pageIndex - base + 1) / 2), setCount - 1)
|
||
}
|
||
|
||
/// Position within the work/rest cycle for the current page — `nil` on the Ready
|
||
/// and Finish pages (which show no dots).
|
||
private var currentCycleIndex: Int? {
|
||
let c = currentPage - base
|
||
return (0..<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))
|
||
}
|
||
|
||
var body: some View {
|
||
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)
|
||
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
|
||
}
|
||
.navigationTitle(log?.exerciseName ?? "")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
} else if startsSkipped {
|
||
splitLayout {
|
||
SkippedPhaseView()
|
||
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
|
||
}
|
||
.navigationTitle(log?.exerciseName ?? "")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
} else {
|
||
flowBody
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
// Bottom half: the looping form-guide figure when a bundled motion
|
||
// matches this exercise; empty space otherwise.
|
||
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
|
||
}
|
||
.navigationTitle(log?.exerciseName ?? "")
|
||
.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()
|
||
}
|
||
.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.
|
||
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).
|
||
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 {
|
||
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))))
|
||
}
|
||
}
|
||
|
||
@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.
|
||
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
|
||
) { 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: restSeconds,
|
||
isActive: isActive,
|
||
anchorStart: anchorStart,
|
||
anchorEnd: anchorEnd
|
||
) { end in
|
||
withAnimation { advance(from: index, phaseEndedAt: end) }
|
||
}
|
||
adjustPill
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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.updatedAt = Date()
|
||
onChange()
|
||
}
|
||
|
||
// 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 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() {
|
||
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: "10 · 8 · 6 reps @ 45 lb" when the
|
||
/// weight is uniform, a per-set "10 × 45 lb · 8 × 42.5 lb" list when it varies,
|
||
/// "45 · 45 · 30 sec" for durations, "10 · 8 · 6 reps" for bodyweight.
|
||
static func actualsLine(_ entries: [SetEntry], weightUnit: WeightUnit) -> String {
|
||
guard !entries.isEmpty else { return "" }
|
||
if entries.contains(where: { $0.seconds != nil }) {
|
||
return entries.map { "\($0.seconds ?? 0)" }.joined(separator: " · ") + " sec"
|
||
}
|
||
let repsList = entries.map { "\($0.reps ?? 0)" }.joined(separator: " · ")
|
||
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: " · ")
|
||
}
|
||
}
|
||
|
||
// 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: - 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
|
||
@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
|
||
|
||
var body: some View {
|
||
VStack(spacing: 10) {
|
||
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
|
||
/// 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) {
|
||
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 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 {
|
||
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 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()
|
||
}
|
||
}
|