8c6e798aba
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
353 lines
12 KiB
Swift
353 lines
12 KiB
Swift
//
|
||
// 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 + (N−1) 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)
|
||
}
|