// // 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 version we've applied, so a redelivery doesn't re-jump. @State private var lastAppliedVersion = 0 /// When a remote frame drove us to a page, that page's timer anchors to the *sender's* /// wall clock (`remoteAnchorStart`/`End`) instead of local `now` — so delivery latency /// can't desync the mirror, the way the old read-only mirror counted off the frame's /// anchors. Scoped to `remoteAnchorPage`; any local transition to another page self-anchors. @State private var remoteAnchorPage: Int? @State private var remoteAnchorStart: Date? @State private var remoteAnchorEnd: 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 /// 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, 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) 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) 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 + (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 } /// 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.. lastAppliedVersion else { return } lastAppliedVersion = frame.version if frame.setCount != setCount { setCount = frame.setCount } let target = page(forPhase: frame.phase, setIndex: frame.setIndex) // 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. remoteAnchorPage = target remoteAnchorStart = frame.phaseStart remoteAnchorEnd = frame.phaseEnd guard target != currentPage else { return } pageChangeCause = .remote withAnimation { currentPage = target } } /// Drop the remote anchor when a local transition moves us off the remote-driven page, /// so the next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor). private func clearRemoteAnchor() { remoteAnchorPage = nil remoteAnchorStart = nil remoteAnchorEnd = 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) -> 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)))) } } /// 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 remote-driven page carries the sender's anchors; every other page (reached // locally by swipe or auto-advance) counts off its own `now`. let anchorStart = index == remoteAnchorPage ? remoteAnchorStart : nil let anchorEnd = index == remoteAnchorPage ? remoteAnchorEnd : 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 ) { 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, 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 ) { 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 mirror 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 (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 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 /// 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.. 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: - 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). let onFinished: () -> 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 if haptic { 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 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 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() } }