Files
workouts/Workouts Watch App/Views/ExerciseProgressView.swift
T
rzen f06c4e996e Rework the Apple Watch progress flow
Watch root lists every in-progress workout; picking an exercise runs a paged Ready -> work/rest -> Finish flow (One More + auto-firing Done), with a phase-dot row and brand-tinted count-up/down timers. Includes the configurable rest and auto-finish settings synced over WatchConnectivity and the wrist-down timer fix.
2026-06-20 14:15:31 -04:00

546 lines
21 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// ExerciseProgressView.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import WatchKit
/// Runs a single exercise as a horizontally-paged flow:
///
/// [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), beeps once per second in the final three
/// seconds, then auto-advances to the next work phase. Every work phase including
/// the last looks the same; sliding past the final set reaches a **Finish** page
/// offering **One More** (append a bonus set and keep going) and **Done** (which also
/// auto-fires after a configurable countdown, completing the exercise).
///
/// 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. 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
/// 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
/// True when this run opens on the lead-in **Ready?** page (the exercise hadn't
/// been started). A resumed exercise or the screenshot host skips it. Constant
/// for the lifetime of the view, so all the page-index math below is stable.
private let showsReady: Bool
/// Forces the starting page (used only by the DEBUG screenshot host). When set it
/// also suppresses the Ready page so the index is a plain work/rest cycle offset.
private let debugInitialPage: Int?
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, debugInitialPage: Int? = nil) {
self._doc = doc
self.logID = logID
self.onChange = onChange
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
let ready = debugInitialPage == nil && notStarted
self.showsReady = ready
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 = ready ? 0 : base + completed * 2
_currentPage = State(initialValue: debugInitialPage ?? resume)
}
private var log: WorkoutLogDocument? {
doc.logs.first { $0.id == logID }
}
/// 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 single Finish page sits just past the last work page.
private var finishIndex: Int { base + cycleCount }
/// The first unfinished set's work page (only used when resuming, so `base == 0`).
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
}
private var detail: String {
guard let log else { return "" }
if LoadType(rawValue: log.loadType) == .duration {
return Self.durationLabel(log.durationSeconds)
}
return "\(log.reps) reps"
}
/// 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 {
TabView(selection: $currentPage) {
ForEach(0..<totalPages, id: \.self) { index in
page(for: index)
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.overlay(alignment: .bottom) {
if let cycleIndex = currentCycleIndex {
PhaseProgressDots(count: cycleCount, currentIndex: cycleIndex)
.padding(.bottom, 2)
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
showingCancelConfirm = true
} label: {
Image(systemName: "xmark")
}
}
}
.confirmationDialog("Cancel Exercise?", isPresented: $showingCancelConfirm, titleVisibility: .visible) {
Button("Cancel Exercise", role: .destructive) { dismiss() }
Button("Continue", role: .cancel) { }
}
.onChange(of: currentPage) { _, newPage in
recordProgress(for: newPage)
}
.onAppear {
// Jump to the first unfinished set. A paged TabView can settle on page 0 on
// first layout, so re-assert once more after this run loop. (The Ready page
// and the screenshot host both pin an explicit page, so skip the jump there.)
guard !didRestorePage else { return }
didRestorePage = true
if !showsReady && debugInitialPage == nil {
jumpToResumePage()
Task { @MainActor in jumpToResumePage() }
}
}
}
/// 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
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) {
// Work phase.
WorkPhaseView(
setNumber: cycleIndex / 2 + 1,
totalSets: setCount,
detail: detail,
isActive: isActive
)
} else {
// Rest phase. Auto-advances to the next work page when the timer hits zero.
RestPhaseView(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.
private func advance(from index: Int) {
guard currentPage == index, index + 1 < totalPages else { return }
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.
///
/// Growing `setCount` and advancing `currentPage` in one animated transaction makes
/// the new work phase slide in from the right. (We land on work, not a rest, because
/// the Finish page's own auto-finish countdown already served as the between-set
/// breather and the rest page would otherwise sit at the very index the Finish
/// page occupied, which a paged TabView can't animate to.)
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()
}
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: - Phase Progress Dots
/// Compact progress row for the work/rest cycle: purple dots for work, teal for rest,
/// with the active phase drawn as a wider dash. Dots are deliberately a touch thick so
/// the count of remaining sets reads at a glance.
private struct PhaseProgressDots: View {
let count: Int
let currentIndex: Int
var body: some View {
HStack(spacing: 4) {
ForEach(0..<count, id: \.self) { i in
let isWork = i.isMultiple(of: 2)
let isCurrent = i == currentIndex
Capsule()
.fill(isWork ? Color.workTint : Color.restTint)
.frame(width: isCurrent ? 12 : 6, height: 6)
.opacity(isCurrent ? 1 : 0.45)
}
}
}
}
// MARK: - Ready Phase
private struct ReadyPhaseView: View {
let summary: String
let onStart: () -> Void
var body: some View {
VStack(spacing: 10) {
Text("Ready?")
.font(.system(size: 30, weight: .bold, design: .rounded))
if !summary.isEmpty {
Text(summary)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Button(action: onStart) {
Label("Start", systemImage: "play.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.workTint)
.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
/// 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 {
VStack(spacing: 6) {
Text("\(setNumber) of \(totalSets)")
.font(.headline)
.foregroundStyle(.secondary)
Text(startDate, style: .timer)
.font(.system(size: 48, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(Color.workTint)
if !detail.isEmpty {
Text(detail)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { if isActive { restart() } }
.onChange(of: isActive) { _, active in if active { restart() } }
}
private func restart() {
startDate = Date()
WKInterfaceDevice.current().play(.start)
}
}
// MARK: - Rest Phase
private struct RestPhaseView: View {
let isActive: Bool
/// Invoked once the countdown reaches zero (auto-advance to the next work phase).
let onFinished: () -> Void
@AppStorage("restSeconds") private var restSeconds: Int = 45
/// Wall-clock window for the countdown. SwiftUI renders the remaining time from this
/// range (so the display keeps counting down in the Always-On / wrist-down state),
/// and the haptics + auto-advance below 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.
@State private var startDate = Date()
@State private var endDate = Date()
/// Lowest remaining-second we've already pinged, so a burst of catch-up ticks after a
/// stall doesn't double-beep.
@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 {
VStack(spacing: 6) {
Text("Rest")
.font(.headline)
.foregroundStyle(.secondary)
Text(timerInterval: startDate...endDate, countsDown: true)
.font(.system(size: 54, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(Color.restTint)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.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, restSeconds)))
lastPingSecond = Int.max
didFinish = false
WKInterfaceDevice.current().play(.start)
}
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 to the next work phase. If the wrist was
// down the timer may have stalled; this then fires on the first tick once the
// app gets runtime again (e.g. the wrist comes up), never losing the rest.
didFinish = true
WKInterfaceDevice.current().play(.stop)
onFinished()
} else if remaining <= 3 && remaining < lastPingSecond {
// Once-per-second countdown ping for the final three seconds.
lastPingSecond = remaining
WKInterfaceDevice.current().play(.notification)
}
}
}
// 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
@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) {
Text("Finished!")
.font(.headline)
.foregroundStyle(.secondary)
Button(action: fire) {
Label(remaining > 0 ? "Done · \(remaining)" : "Done", systemImage: "checkmark")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(Color.workTint)
Button(action: onOneMore) {
Label("One More", systemImage: "plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
.padding(.horizontal)
.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
WKInterfaceDevice.current().play(.success)
onDone()
}
}