9c3d146b89
Records recent ExerciseProgressView lifecycle/mutation events (init count, page changes, bridge updates, phase starts) and shows them as a green overlay so they can be read off a frozen screen on-device. To be reverted once the hang is diagnosed.
720 lines
27 KiB
Swift
720 lines
27 KiB
Swift
//
|
||
// ExerciseProgressView.swift
|
||
// Workouts Watch App
|
||
//
|
||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||
//
|
||
|
||
import SwiftUI
|
||
import WatchKit
|
||
|
||
// DIAGNOSTIC (temporary): records recent lifecycle/mutation events so the on-screen
|
||
// overlay can show what ran right before a freeze. Remove once the hang is solved.
|
||
enum PVDiag {
|
||
nonisolated(unsafe) static var initCount = 0
|
||
nonisolated(unsafe) static var lines: [String] = []
|
||
static func add(_ s: String) {
|
||
print("⌚️PV \(s)")
|
||
lines.append(s)
|
||
if lines.count > 7 { lines.removeFirst(lines.count - 7) }
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
|
||
/// Rest length between sets, shared with the phone via the same defaults key.
|
||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||
|
||
/// 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. Held in `@State`
|
||
/// so it stays fixed for the life of the screen (same reasoning as the iPhone view),
|
||
/// keeping the page-index math below stable.
|
||
@State private var 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
|
||
_showsReady = State(initialValue: 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)
|
||
PVDiag.initCount += 1
|
||
PVDiag.add("init: ready=\(ready) page=\(debugInitialPage ?? resume) sets=\(sets) duration=\(LoadType(rawValue: log?.loadType ?? -1) == .duration)")
|
||
}
|
||
|
||
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 + (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 (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
|
||
}
|
||
|
||
/// 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 {
|
||
TabView(selection: $currentPage) {
|
||
ForEach(0..<totalPages, id: \.self) { index in
|
||
page(for: index)
|
||
.tag(index)
|
||
}
|
||
}
|
||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||
.overlay(alignment: .bottom) {
|
||
if let dots = workDots {
|
||
WorkPhaseDots(model: dots)
|
||
.padding(.bottom, 2)
|
||
}
|
||
}
|
||
// DIAGNOSTIC overlay (temporary): shows recent events; readable off a frozen screen.
|
||
.overlay(alignment: .top) {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text("init#\(PVDiag.initCount)").bold()
|
||
ForEach(Array(PVDiag.lines.enumerated()), id: \.offset) { _, line in
|
||
Text(line)
|
||
}
|
||
}
|
||
.font(.system(size: 8, design: .monospaced))
|
||
.foregroundStyle(.green)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(.black.opacity(0.65))
|
||
.allowsHitTesting(false)
|
||
}
|
||
.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
|
||
PVDiag.add("currentPage -> \(newPage)")
|
||
// Swiping all the way back to the Ready page wipes the run; any other page
|
||
// records forward progress.
|
||
if showsReady && newPage == 0 {
|
||
resetExercise()
|
||
} else {
|
||
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) {
|
||
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.
|
||
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() {
|
||
PVDiag.add("beginExercise -> onChange (bridge.update)")
|
||
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 }
|
||
|
||
PVDiag.add("recordProgress reached=\(reached) -> onChange (bridge.update)")
|
||
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 }
|
||
PVDiag.add("resetExercise -> onChange (bridge.update)")
|
||
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
|
||
|
||
/// 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: 4) {
|
||
Text(header)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
|
||
timer
|
||
.font(.system(size: 50, weight: .bold, design: .rounded))
|
||
.monospacedDigit()
|
||
.foregroundStyle(tint)
|
||
|
||
Text(footer.isEmpty ? " " : footer)
|
||
.font(.caption)
|
||
.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 = 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..<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 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
|
||
|
||
/// 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) {
|
||
Text(startDate, style: .timer)
|
||
}
|
||
.onAppear { if isActive { restart() } }
|
||
.onChange(of: isActive) { _, active in if active { restart() } }
|
||
}
|
||
|
||
private func restart() {
|
||
PVDiag.add("work.restart set=\(setNumber) (beep)")
|
||
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").
|
||
///
|
||
/// 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
|
||
/// 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) {
|
||
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() {
|
||
PVDiag.add("countdown.start '\(header)' seconds=\(seconds) (beep)")
|
||
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 {
|
||
// 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.
|
||
PVDiag.add("countdown.finished '\(header)' -> advance")
|
||
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
|
||
|
||
@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) }
|
||
.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()
|
||
}
|
||
}
|