From 3bba78eab572add120e180450a396b455d0dd8fe Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 20 Jun 2026 14:15:43 -0400 Subject: [PATCH] Open a paged progress run when tapping an iPhone exercise Tapping an exercise now opens ExerciseProgressView -- the watch's Ready -> work/rest -> Finish flow on iPhone, with rep sets counting up, timed sets and rests counting down and auto-advancing, a work-set dot row (dash on the active set, gap widening at the current rest), and capsule Start/Done/One More buttons. The detail/edit screen moves behind a trailing Edit swipe (leading swipe still completes). Swiping back to the Ready page resets the run. --- CHANGELOG.md | 82 +-- README.md | 16 +- .../WorkoutLogs/ExerciseProgressView.swift | 697 ++++++++++++++++++ .../WorkoutLogs/WorkoutLogListView.swift | 36 +- 4 files changed, 753 insertions(+), 78 deletions(-) create mode 100644 Workouts/Views/WorkoutLogs/ExerciseProgressView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d0b3e5..d3c953a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,66 +1,26 @@ -# Changelog +**Changelog** All notable changes to this project are documented here. -## June 2026 +**June 2026** -- Fixed the Apple Watch work/rest timers freezing when the wrist was lowered. They - counted by incrementing a per-second `Timer`, which watchOS throttles in the - Always-On (dimmed) state; they now derive from a wall-clock anchor rendered with - SwiftUI's self-updating timer text, so the time keeps advancing while dimmed and is - correct the instant the wrist comes back up. Rest haptics and auto-advance are - driven off the end time too, so they catch up after a stall instead of stalling. -- Keep the iPhone screen awake while the exercise detail screen is open, so the - display no longer sleeps mid-set. (The Apple Watch already stays awake during a - workout via its HealthKit workout session.) -- Set the iPhone app to iPhone-only (`TARGETED_DEVICE_FAMILY` 1); it was - inadvertently universal but isn't an iPad-shaped experience. The Apple Watch app - is a separate target and is unaffected. -- Exercise detail now renders the set-progress grid correctly on the first frame - (seeded from the log in `init`) instead of filling in a frame later. -- Added a DEBUG-only screenshot harness (seeded sample data + `--screenshot - --screen ` launch args, excluded from release builds) for generating App - Store screenshots from the iPhone and Apple Watch simulators, plus the - `Scripts/metadata/` App Store listing source of truth. -- Redesigned the Apple Watch app into a focused workout runner: it opens directly - on the active workout's exercise list (or prompts you to start one on iPhone), - and each exercise runs as a horizontally-paged HIIT cycle — a count-up work - phase, swipe to a count-down rest that pings once per second in the final three - seconds then auto-advances to the next set, and **One More** / **Done** buttons - on the final set. -- Added a configurable rest-between-sets duration (iPhone Settings, default 45s), - synced to the watch over WatchConnectivity. -- Watch progress is now monotonic and reliably synced: completing a work phase - advances the set count on the iPhone and a finished set is never un-counted, and - reopening an exercise jumps straight to the first unfinished set (skipping - completed work/rest pairs) instead of snapping back to set 1. -- Fixed: progress made on the watch now updates open iPhone screens live. The - phone applies a watch-forwarded workout to its cache directly on receipt, instead - of waiting on an `NSMetadataQuery` event that a same-process file overwrite - doesn't reliably emit — and the exercise detail screen now observes these updates, - so its set grid advances in real time without leaving and re-entering the screen. -- Starting a workout on the iPhone now launches the Apple Watch app straight into - the session via HealthKit (a one-time Health permission); the watch holds an - `HKWorkoutSession` to stay active while you train and releases it when the - workout finishes. -- New app icon: a tilted dumbbell on a purple gradient, full-bleed across iOS and - Watch (replaces the teal circular mark). -- **2.0** — Re-platformed persistence onto an iCloud Drive document architecture: - JSON files in iCloud Drive are now the sole source of truth, with a rebuildable - SwiftData cache populated by an `NSMetadataQuery` observer. Removed - CloudKit/`NSPersistentCloudKitContainer` and the App-Group store. -- Rebuilt the Apple Watch sync on a new WatchConnectivity bridge keyed by stable - ULIDs (the phone is the sole writer of iCloud Drive). -- Migrated the project to XcodeGen; iOS 26 / watchOS 26, Swift 6 strict - concurrency. -- Splits ship as an on-demand machine-based starter routine (Upper Body, Core, - Lower Body) at 4×10 with sensible starting weights. +- Tapping an exercise on iPhone now opens a paged **progress run** — the same **Ready?** → work / rest → **Finish** flow as the Apple Watch (with **One More** and an auto-firing **Done**), now with iPhone haptics. Rep-based sets count up (swipe on when done); timed sets and rests count down and auto-advance. A dot row tracks progress with one dot per set — the active set drawn as a dash, with the gap widening at the rest you're currently in. The detail/edit screen (set grid, plan, notes, weight chart) is unchanged but moved behind a new **Edit** swipe on the trailing edge, alongside Delete; the leading swipe still completes. The paged flow fills the top of the screen for now, with the lower half reserved for a later iteration. +- Reworked the Apple Watch progress flow. The root now lists every in-progress workout (rather than diving into a single one); picking one shows its exercises, and picking an exercise opens a paged run: a lead-in **Ready?** page with a **Start** button (shown only when the exercise hasn't begun), the count-up work phases and count-down rests, then a dedicated **Finish** page with **One More** and a **Done** button that auto-completes after a configurable countdown (iPhone Settings → Auto-Finish Countdown, default 5s, synced to the watch). Trimmed the "swipe to skip/rest" hints and added a phase-progress dot row — purple dots for work, teal for rest, the current phase a wider dash — with the count-up/down timers tinted to match (brand purple / light teal). +- Fixed the Apple Watch work/rest timers freezing when the wrist was lowered. They counted by incrementing a per-second `Timer`, which watchOS throttles in the Always-On (dimmed) state; they now derive from a wall-clock anchor rendered with SwiftUI's self-updating timer text, so the time keeps advancing while dimmed and is correct the instant the wrist comes back up. Rest haptics and auto-advance are driven off the end time too, so they catch up after a stall instead of stalling. +- Keep the iPhone screen awake while the exercise detail screen is open, so the display no longer sleeps mid-set. (The Apple Watch already stays awake during a workout via its HealthKit workout session.) +- Set the iPhone app to iPhone-only (`TARGETED_DEVICE_FAMILY` 1); it was inadvertently universal but isn't an iPad-shaped experience. The Apple Watch app is a separate target and is unaffected. +- Exercise detail now renders the set-progress grid correctly on the first frame (seeded from the log in `init`) instead of filling in a frame later. +- Added a DEBUG-only screenshot harness (seeded sample data + `--screenshot --screen ` launch args, excluded from release builds) for generating App Store screenshots from the iPhone and Apple Watch simulators, plus the `Scripts/metadata/` App Store listing source of truth. +- Redesigned the Apple Watch app into a focused workout runner: it opens directly on the active workout's exercise list (or prompts you to start one on iPhone), and each exercise runs as a horizontally-paged HIIT cycle — a count-up work phase, swipe to a count-down rest that pings once per second in the final three seconds then auto-advances to the next set, and **One More** / **Done** buttons on the final set. +- Added a configurable rest-between-sets duration (iPhone Settings, default 45s), synced to the watch over WatchConnectivity. +- Watch progress is now monotonic and reliably synced: completing a work phase advances the set count on the iPhone and a finished set is never un-counted, and reopening an exercise jumps straight to the first unfinished set (skipping completed work/rest pairs) instead of snapping back to set 1. +- Fixed: progress made on the watch now updates open iPhone screens live. The phone applies a watch-forwarded workout to its cache directly on receipt, instead of waiting on an `NSMetadataQuery` event that a same-process file overwrite doesn't reliably emit — and the exercise detail screen now observes these updates, so its set grid advances in real time without leaving and re-entering the screen. +- Starting a workout on the iPhone now launches the Apple Watch app straight into the session via HealthKit (a one-time Health permission); the watch holds an `HKWorkoutSession` to stay active while you train and releases it when the workout finishes. +- New app icon: a tilted dumbbell on a purple gradient, full-bleed across iOS and Watch (replaces the teal circular mark). +- **2.0** — Re-platformed persistence onto an iCloud Drive document architecture: JSON files in iCloud Drive are now the sole source of truth, with a rebuildable SwiftData cache populated by an `NSMetadataQuery` observer. Removed CloudKit/`NSPersistentCloudKitContainer` and the App-Group store. +- Rebuilt the Apple Watch sync on a new WatchConnectivity bridge keyed by stable ULIDs (the phone is the sole writer of iCloud Drive). +- Migrated the project to XcodeGen; iOS 26 / watchOS 26, Swift 6 strict concurrency. +- Splits ship as an on-demand machine-based starter routine (Upper Body, Core, Lower Body) at 4×10 with sensible starting weights. - Stored exercise/log durations as integer seconds (was a `Date` epoch hack). -- Fixed: workout marked complete on creation, an undismissable delete dialog, - toolbar buttons hidden by nested navigation stacks, and a placeholder - "Settings coming soon" row. -- Fixed: tapping an exercise in a workout log pushed the wrong screen (a - duplicate of the split list) with the exercise detail hidden underneath — a - single row tap was navigating twice. Caused by stacking two - `navigationDestination` modifiers on the log list; rows now use a single - destination-based link. +- Fixed: workout marked complete on creation, an undismissable delete dialog, toolbar buttons hidden by nested navigation stacks, and a placeholder "Settings coming soon" row. +- Fixed: tapping an exercise in a workout log pushed the wrong screen (a duplicate of the split list) with the exercise detail hidden underneath — a single row tap was navigating twice. Caused by stacking two `navigationDestination` modifiers on the log list; rows now use a single destination-based link. diff --git a/README.md b/README.md index d06a644..5dc0564 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,19 @@ your own iCloud Drive. Lower Body) generated from a bundled exercise catalog. - **Exercise library** — a bundled catalog of starter exercises (bodyweight and machine-based) to populate your splits. -- **Run a workout** — start a session from a split, track sets/reps/weight or - timed exercises, and mark exercises complete. +- **Run a workout** — start a session from a split, then tap an exercise to run it + as a paged flow: a **Ready?** lead-in, count-up work phases, count-down rests, and + a **Finish** page — mirroring the Apple Watch. Swipe a row to mark it complete, or + swipe to edit its plan (sets/reps/weight or duration) and notes. - **Progress tracking** — weight-progression charts per exercise across past sessions. - **Apple Watch companion** — starting a workout on the iPhone launches the watch - app straight into it; run the session from your wrist as a HIIT cycle: count-up - work phases, count-down rests with final-three-second haptics and auto-advance, - and **One More** / **Done** on the last set. Rest time is configurable; changes - sync back to the phone. + app straight into it. The watch lists your in-progress workouts; pick one, pick an + exercise, and run it as a paged flow: a **Ready?** lead-in, count-up work phases, + count-down rests with final-three-second haptics and auto-advance, and a **Finish** + page with **One More** and a **Done** that auto-completes after a countdown. A + phase-dot row (purple work, teal rest) tracks progress. Rest time and the + auto-finish countdown are configurable; changes sync back to the phone. - **iCloud Drive sync** — your data lives as human-readable JSON in your iCloud Drive, synced across devices and visible in the Files app. iCloud is required. diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift new file mode 100644 index 0000000..f57c950 --- /dev/null +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -0,0 +1,697 @@ +// +// ExerciseProgressView.swift +// Workouts +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import UIKit + +/// Runs a single exercise as a horizontally-paged flow — the iPhone counterpart to the +/// watch's `ExerciseProgressView`: +/// +/// [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), buzzes once per second in the final three +/// seconds, then auto-advances to the next work phase. Sliding past the final set +/// reaches a **Finish** page offering **One More** (append a bonus set) and **Done** +/// (which also auto-fires after a configurable countdown, completing the exercise). +/// +/// The paged flow occupies the **top half** of the screen; the bottom half is reserved +/// blank for a later iteration. 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 list. We mutate the + /// matching log in place and ask the parent to persist each change — driving the UI + /// from this doc (not the cache) avoids losing rapid edits to the read-after-write + /// race the cache update lags behind. + @Binding var doc: WorkoutDocument + let logID: String + let onChange: () -> Void + + /// Rest length between sets, shared with the watch 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 didRestorePage = false + + /// True when this run opens on the lead-in **Ready?** page (the exercise hadn't + /// been started yet). Held in `@State` so it stays fixed for the life of the screen: + /// the parent list rebuilds (re-inits) this view whenever the workout file changes, + /// and once Start marks the exercise in-progress a recomputed `let` would flip to + /// `false` mid-run — dropping `base` from 1 to 0 and remapping the current page onto + /// the wrong phase. Frozen here, all the page-index math below stays stable. + @State private var showsReady: Bool + + init(doc: Binding, 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) + + let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue + _showsReady = State(initialValue: notStarted) + + let base = notStarted ? 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 = notStarted ? 0 : base + completed * 2 + _currentPage = State(initialValue: 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 + (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.. 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() { + 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, 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 + +/// Maps the watch flow's haptic vocabulary onto UIKit feedback generators so the iPhone +/// flow buzzes at the same beats (set start, countdown ping, rest end, done). +private enum WorkoutHaptic { + case start, tick, stop, success + + @MainActor + func play() { + switch self { + case .start: + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + case .tick: + UIImpactFeedbackGenerator(style: .light).impactOccurred() + case .stop: + UIImpactFeedbackGenerator(style: .rigid).impactOccurred() + case .success: + UINotificationFeedbackGenerator().notificationOccurred(.success) + } + } +} + +// MARK: - Phase Colors + +private extension Color { + /// Count-up work tint — brand purple, brightened in dark mode for contrast on + /// black and deepened in light mode for contrast on white. + static let workTimer = Color(UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0.66, green: 0.45, blue: 0.96, alpha: 1) + : UIColor(red: 0.45, green: 0.18, blue: 0.78, alpha: 1) + }) + + /// Count-down rest tint — a light gray that deepens in light mode so it stays + /// legible on a white background. + static let restTimer = Color(UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(white: 0.74, alpha: 1) + : UIColor(white: 0.52, alpha: 1) + }) +} + +// 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: View { + let header: String + let footer: String + let tint: Color + @ViewBuilder var timer: Content + + var body: some View { + VStack(spacing: 10) { + Text(header) + .font(.title3) + .foregroundStyle(.secondary) + + timer + .font(.system(size: 108, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.5) + + Text(footer.isEmpty ? " " : footer) + .font(.title3) + .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 = 8 + private let dashWidth: CGFloat = 20 + private let markerHeight: CGFloat = 8 + private let gap: CGFloat = 8 + 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 taller full-width body. + func phaseButtonLabel() -> some View { + self + .font(.system(.title2, design: .rounded, weight: .heavy)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } +} + +// MARK: - Ready Phase + +private struct ReadyPhaseView: View { + let summary: String + let onStart: () -> Void + + var body: some View { + VStack(spacing: 14) { + Text("Ready?") + .font(.system(size: 44, weight: .bold, design: .rounded)) + + if !summary.isEmpty { + Text(summary) + .font(.title3) + .foregroundStyle(.secondary) + } + + Button(action: onStart) { + Text("Start") + .phaseButtonLabel() + } + .buttonStyle(.borderedProminent) + .tint(.workTimer) + .buttonBorderShape(.capsule) + .padding(.top, 8) + .padding(.horizontal, 40) + } + .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 + /// keeps it advancing without a run-loop timer. + @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() { + 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"). +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 + + /// Wall-clock window for the countdown. SwiftUI renders the remaining time from this + /// range, and the haptics + auto-advance below are derived from `endDate` rather than + /// a decremented counter so they stay correct even if a tick is delayed. + @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() { + 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 { + 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 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. `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: 14) { + 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, 40) + .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() + } +} diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index 787af62..721d699 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -24,14 +24,15 @@ struct WorkoutLogListView: View { @State private var showingAddSheet = false @State private var logToDelete: WorkoutLogDocument? - @State private var addedLog: AddedLogRoute? + @State private var addedLog: LogRoute? + @State private var logToEdit: LogRoute? - /// Drives the programmatic push into a freshly-added exercise via - /// `navigationDestination(item:)`. Rows navigate with plain destination-based - /// `NavigationLink`s, so this is the only `navigationDestination` in the view — - /// stacking a second one (e.g. a value-based `for: String.self`) made a single - /// row tap push twice. - private struct AddedLogRoute: Identifiable, Hashable { let id: String } + /// Drives a programmatic push keyed by a log id, used for the freshly-added + /// exercise (→ progress flow) and the Edit swipe (→ detail/edit screen). Rows + /// navigate with plain destination-based `NavigationLink`s; these item-based + /// `navigationDestination`s are each bound to their own state, so they don't + /// double-fire the way a value-based `navigationDestination(for:)` would. + private struct LogRoute: Identifiable, Hashable { let id: String } init(workout: Workout) { self.workout = workout @@ -68,7 +69,7 @@ struct WorkoutLogListView: View { Section(header: Text(label)) { ForEach(sortedLogs) { log in NavigationLink { - ExerciseView(workout: workout, logID: log.id) + ExerciseProgressView(doc: $doc, logID: log.id, onChange: { save() }) } label: { CheckboxListItem( status: workoutStatus(log).checkboxStatus, @@ -93,6 +94,13 @@ struct WorkoutLogListView: View { Label("Delete", systemImage: "trash") } .tint(.red) + + Button { + logToEdit = LogRoute(id: log.id) + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) } } .onMove(perform: moveLog) @@ -101,8 +109,14 @@ struct WorkoutLogListView: View { } } .navigationDestination(item: $addedLog) { route in - // Seed with our working doc so the brand-new log is available before - // the cache catches up. + // A freshly-added exercise drops straight into its progress flow. The new + // log already lives in our working doc, so the binding has it before the + // cache catches up. + ExerciseProgressView(doc: $doc, logID: route.id, onChange: { save() }) + } + .navigationDestination(item: $logToEdit) { route in + // The Edit swipe opens the detail/edit screen, seeded with our working + // doc so it shows the latest local state immediately. ExerciseView(workout: workout, logID: route.id, seedDoc: doc) } .navigationTitle(doc.splitName ?? Split.unnamed) @@ -234,7 +248,7 @@ struct WorkoutLogListView: View { save() // Push the new exercise straight away. - addedLog = AddedLogRoute(id: newLog.id) + addedLog = LogRoute(id: newLog.id) } /// Recompute the workout's status/end from its logs, then persist.