Files
workouts/Workouts Watch App/Views/ExerciseProgressView.swift
T
rzen 8c6e798aba Make watch HIIT progress monotonic and resume to next unfinished set
Completing a work phase advances the set count to the iPhone, and a finished
set is never un-counted — a transient paged-TabView snap to page 0 can no longer
overwrite progress with 0. Reopening an exercise now jumps to the first
unfinished set's work page (re-asserted after first layout) instead of starting
back at set 1.

Claude-Session: https://claude.ai/code/session_018gg69MaUetDNzWzBXisfMV
2026-06-19 16:41:41 -04:00

353 lines
12 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 HIIT cycle:
///
/// Work Rest Work Rest Rest Work
///
/// The **work** phase counts *up* (a stopwatch for the current set); the user swipes
/// left when they're done. The **rest** phase counts *down* from the configurable rest
/// time, beeps once per second in the final three seconds, and then auto-advances to
/// the next work phase. The final work phase has no rest after it instead it offers
/// **One More** (append a bonus set and keep going) and **Done** (mark the exercise
/// complete and return to the list).
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
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void) {
self._doc = doc
self.logID = logID
self.onChange = onChange
let log = doc.wrappedValue.logs.first { $0.id == logID }
let sets = max(1, log?.sets ?? 1)
_setCount = State(initialValue: sets)
// Resume on the first unfinished set's work page (clamped to the last set).
let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1)
_currentPage = State(initialValue: completed * 2)
}
private var log: WorkoutLogDocument? {
doc.logs.first { $0.id == logID }
}
/// Work, Rest, , Work N sets + (N1) rests = 2N 1 pages.
private var totalPages: Int { setCount * 2 - 1 }
/// The first unfinished set's work page (clamped to the last set). Resuming an
/// exercise opens here, skipping any completed work/rest pairs.
private var resumePage: Int {
let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1)
return completed * 2
}
private var detail: String {
guard let log else { return "" }
if LoadType(rawValue: log.loadType) == .duration {
return Self.durationLabel(log.durationSeconds)
}
return "\(log.reps) reps"
}
var body: some View {
TabView(selection: $currentPage) {
ForEach(0..<totalPages, id: \.self) { index in
page(for: index)
.tag(index)
}
}
.tabViewStyle(.page)
.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.
guard !didRestorePage else { return }
didRestorePage = true
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 index.isMultiple(of: 2) {
// Work phase. The last page (set N) finishes the exercise.
WorkPhaseView(
setNumber: index / 2 + 1,
totalSets: setCount,
detail: detail,
isLast: index == totalPages - 1,
isActive: isActive,
onOneMore: addSet,
onDone: { completeExercise(); dismiss() }
)
} else {
// Rest phase. Auto-advances to the next work page when the timer hits zero.
RestPhaseView(isActive: isActive) {
withAnimation { advance(from: index) }
}
}
}
// MARK: - Mutations
/// 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
}
/// Append a bonus set: grow the plan, record the just-finished set as done, and
/// slide into the rest period that now follows the previously-final work page.
private func addSet() {
let restPage = currentPage + 1 // the rest page that becomes valid after the bump
setCount += 1
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
doc.logs[i].sets = setCount
doc.logs[i].currentStateIndex = setCount - 1 // the old final set is complete
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
}
withAnimation { currentPage = restPage }
}
/// Page index completed-set count: Work(0)0, Rest(1)1, Work(2)1,
/// i.e. `(pageIndex + 1) / 2`. Reaching set N as *completed* only happens via Done.
///
/// 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 finished set.
private func recordProgress(for pageIndex: Int) {
let reached = min((pageIndex + 1) / 2, setCount)
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
if reached >= setCount {
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
} else {
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: - Work Phase
private struct WorkPhaseView: View {
let setNumber: Int
let totalSets: Int
let detail: String
let isLast: Bool
let isActive: Bool
let onOneMore: () -> Void
let onDone: () -> Void
@State private var elapsed = 0
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
var body: some View {
VStack(spacing: 6) {
Text("Set \(setNumber) of \(totalSets)")
.font(.headline)
.foregroundStyle(.secondary)
Text(clockString(elapsed))
.font(.system(size: 48, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.green)
Text(detail)
.font(.subheadline)
.foregroundStyle(.secondary)
if isLast {
VStack(spacing: 6) {
Button(action: onOneMore) {
Label("One More", systemImage: "plus")
.frame(maxWidth: .infinity)
}
.tint(.blue)
Button(action: onDone) {
Label("Done", systemImage: "checkmark")
.frame(maxWidth: .infinity)
}
.tint(.green)
}
.buttonStyle(.borderedProminent)
.padding(.top, 4)
} else {
Label("Swipe to rest", systemImage: "chevron.right")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { if isActive { restart() } }
.onChange(of: isActive) { _, active in if active { restart() } }
.onReceive(ticker) { _ in if isActive { elapsed += 1 } }
}
private func restart() {
elapsed = 0
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
@State private var remaining = 0
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(clockString(max(0, remaining)))
.font(.system(size: 54, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.orange)
Label("Swipe to skip", systemImage: "chevron.right")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { if isActive { start() } }
.onChange(of: isActive) { _, active in if active { start() } }
.onReceive(ticker) { _ in tick() }
}
private func start() {
remaining = max(1, restSeconds)
WKInterfaceDevice.current().play(.start)
}
private func tick() {
guard isActive, remaining > 0 else { return }
remaining -= 1
if remaining == 0 {
// Time's up final cue and slide to the next work phase.
WKInterfaceDevice.current().play(.stop)
onFinished()
} else if remaining <= 3 {
// Once-per-second countdown ping for the final three seconds.
WKInterfaceDevice.current().play(.notification)
}
}
}
// MARK: - Shared
private func clockString(_ seconds: Int) -> String {
String(format: "%d:%02d", seconds / 60, seconds % 60)
}