Files
workouts/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift
T
rzen 8f69497b24 Make the live run two-way: drive from either device
The propped-up iPhone now runs the real ExerciseProgressView for a live
watch workout instead of a read-only mirror, and the live-run channel is
symmetric — either device can drive the flow and the other follows.

Each page transition is classified human / auto / remote: only human
transitions (swipe, Start, One More, swipe-back reset) are broadcast and
recorded by the actor; auto-advances (rest / timed-work countdown) record
locally but aren't sent, since both devices reach them independently off
the shared wall-clock anchors; an applied remote frame jumps the page
without re-recording or re-broadcasting. That rule is also what stops an
echo loop.

- PhoneConnectivityBridge gains sendLiveProgress/sendLiveEnded (the
  missing phone->watch direction); WatchConnectivityBridge receives
  frames into an observable liveIncoming via a new didReceiveMessage
  route. Both share one increasing per-run version sequence so the
  stale-frame guard works across the two devices' counters.
- Both ExerciseProgressViews gain an incomingFrame input + applyIncoming
  (syncing setCount for a remote One More); the iPhone one gains the
  liveSnapshot/broadcast machinery the watch already had.
- New LiveRunCoverView wraps the real driver for the cover (resolves the
  workout, persists via SyncEngine, wires the live channel + close);
  ContentView presents it; LiveProgressMirrorView is removed.

Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
2026-06-20 22:11:05 -04:00

844 lines
33 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// ExerciseProgressView.swift
// Workouts
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import UIKit
/// Runs a single exercise as a horizontally-paged flow the iPhone counterpart to the
/// watch's `ExerciseProgressView`:
///
/// [Ready] Work Rest Work Work Finish
///
/// A **Ready?** page leads in *only* when the exercise hasn't been started yet (a
/// resumed exercise jumps straight to its first unfinished set). The **work** phase
/// counts *up* (a stopwatch for the current set, tinted with the brand purple); the
/// user swipes left when they're done. The **rest** phase counts *down* from the
/// configurable rest time (light teal), buzzes once per second in the final three
/// seconds, then auto-advances to the next work phase. Sliding past the final set
/// reaches a **Finish** page offering **One More** (append a bonus set) and **Done**
/// (which also auto-fires after a configurable countdown, completing the exercise).
///
/// The paged flow occupies the **top half** of the screen; the bottom half is reserved
/// blank for a later iteration. 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
/// 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
/// 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
/// 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 version we've applied, so a redelivery doesn't re-jump.
@State private var lastAppliedVersion = 0
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
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
self._doc = doc
self.logID = logID
self.onChange = onChange
self.onLive = onLive
self.onLiveEnded = onLiveEnded
self.incomingFrame = incomingFrame
let log = doc.wrappedValue.logs.first { $0.id == logID }
let sets = max(1, log?.sets ?? 1)
_setCount = State(initialValue: sets)
// 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)
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 + (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
}
/// 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 {
VStack(spacing: 0) {
// 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)
}
}
// Reserved space for a later iteration (set log, history, chart, ).
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.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.
recordProgress(for: newPage)
case .none:
// A human swipe. Swiping back from the first set to Ready wipes the run
// (only the adjacent 10 swipe resets a stray far jump never does); any
// other page records forward progress. Human transitions are broadcast.
if showsReady && newPage == 0 && oldPage == base {
resetExercise()
} else {
recordProgress(for: newPage)
}
broadcastLive(for: newPage)
}
}
.onChange(of: incomingFrame) { _, frame in
if let frame { applyIncoming(frame) }
}
.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.
onLiveEnded()
}
}
/// 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)
}
}
/// 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.version > lastAppliedVersion else { return }
lastAppliedVersion = frame.version
if frame.setCount != setCount { setCount = frame.setCount }
let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
guard target != currentPage else { return }
pageChangeCause = .remote
withAnimation { currentPage = target }
}
/// Inverse of `liveSnapshot`'s pageframe 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) -> LiveProgress? {
guard let log else { return nil }
let now = Date()
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
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
)
} 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
) {
withAnimation { advance(from: index) }
}
} else {
// Rep-based work set count up; the user swipes left when done.
WorkPhaseView(
setNumber: setNumber,
totalSets: setCount,
detail: detail,
isActive: isActive
)
}
} else {
// Rest phase. Auto-advances to the next work page when the timer hits zero.
CountdownPhaseView(
header: "Rest",
tint: .restTimer,
seconds: restSeconds,
isActive: isActive
) {
withAnimation { advance(from: index) }
}
}
}
}
// 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 (used by the rest auto-advance), 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).
private func advance(from index: Int) {
guard currentPage == index, index + 1 < totalPages else { return }
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].status = WorkoutStatus.inProgress.rawValue
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
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
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
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 cycleIndex = pageIndex - base
let reached = min(max(0, (cycleIndex + 1) / 2), setCount - 1)
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
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
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).
guard log.currentStateIndex != 0
|| log.status != WorkoutStatus.notStarted.rawValue
|| log.completed else { return }
doc.logs[i].currentStateIndex = 0
doc.logs[i].status = WorkoutStatus.notStarted.rawValue
doc.logs[i].completed = false
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
}
private func completeExercise() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].currentStateIndex = setCount
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
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
/// 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
var body: some View {
VStack(spacing: 10) {
Text(header)
.font(.title3)
.foregroundStyle(.secondary)
timer
.font(.system(size: 108, 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: - Ready Phase
private struct ReadyPhaseView: View {
let summary: String
let onStart: () -> Void
var body: some View {
VStack(spacing: 14) {
Text("Ready?")
.font(.system(size: 44, weight: .bold, design: .rounded))
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
/// 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() } }
.onChange(of: isActive) { _, active in if active { restart() } }
}
private func restart() {
startDate = Date()
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
/// Invoked once the countdown reaches zero (auto-advance to the next page).
let onFinished: () -> 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() } }
.onChange(of: isActive) { _, active in if active { start() } }
.onReceive(ticker) { _ in tick() }
}
private func start() {
startDate = Date()
endDate = startDate.addingTimeInterval(Double(max(1, seconds)))
lastPingSecond = Int.max
didFinish = false
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
WorkoutHaptic.stop.play()
onFinished()
} 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
@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) }
.onReceive(ticker) { _ in tick() }
}
private func start() {
let total = max(1, doneCountdownSeconds)
endDate = Date().addingTimeInterval(Double(total))
remaining = total
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()
}
}