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.
This commit is contained in:
2026-06-20 14:15:43 -04:00
parent f06c4e996e
commit 3bba78eab5
4 changed files with 753 additions and 78 deletions
+21 -61
View File
@@ -1,66 +1,26 @@
# Changelog **Changelog**
All notable changes to this project are documented here. 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 - 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.
counted by incrementing a per-second `Timer`, which watchOS throttles in the - 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).
Always-On (dimmed) state; they now derive from a wall-clock anchor rendered with - 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.
SwiftUI's self-updating timer text, so the time keeps advancing while dimmed and is - 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.)
correct the instant the wrist comes back up. Rest haptics and auto-advance are - 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.
driven off the end time too, so they catch up after a stall instead of stalling. - 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.
- Keep the iPhone screen awake while the exercise detail screen is open, so the - Added a DEBUG-only screenshot harness (seeded sample data + `--screenshot --screen <name>` 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.
display no longer sleeps mid-set. (The Apple Watch already stays awake during a - 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.
workout via its HealthKit workout session.) - Added a configurable rest-between-sets duration (iPhone Settings, default 45s), synced to the watch over WatchConnectivity.
- Set the iPhone app to iPhone-only (`TARGETED_DEVICE_FAMILY` 1); it was - 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.
inadvertently universal but isn't an iPad-shaped experience. The Apple Watch app - 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.
is a separate target and is unaffected. - 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.
- Exercise detail now renders the set-progress grid correctly on the first frame - New app icon: a tilted dumbbell on a purple gradient, full-bleed across iOS and Watch (replaces the teal circular mark).
(seeded from the log in `init`) instead of filling in a frame later. - **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.
- Added a DEBUG-only screenshot harness (seeded sample data + `--screenshot - Rebuilt the Apple Watch sync on a new WatchConnectivity bridge keyed by stable ULIDs (the phone is the sole writer of iCloud Drive).
--screen <name>` launch args, excluded from release builds) for generating App - Migrated the project to XcodeGen; iOS 26 / watchOS 26, Swift 6 strict concurrency.
Store screenshots from the iPhone and Apple Watch simulators, plus the - Splits ship as an on-demand machine-based starter routine (Upper Body, Core, Lower Body) at 4×10 with sensible starting weights.
`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). - Stored exercise/log durations as integer seconds (was a `Date` epoch hack).
- Fixed: workout marked complete on creation, an undismissable delete dialog, - Fixed: workout marked complete on creation, an undismissable delete dialog, toolbar buttons hidden by nested navigation stacks, and a placeholder "Settings coming soon" row.
toolbar buttons hidden by nested navigation stacks, and a placeholder - 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.
"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.
+10 -6
View File
@@ -11,15 +11,19 @@ your own iCloud Drive.
Lower Body) generated from a bundled exercise catalog. Lower Body) generated from a bundled exercise catalog.
- **Exercise library** — a bundled catalog of starter exercises (bodyweight and - **Exercise library** — a bundled catalog of starter exercises (bodyweight and
machine-based) to populate your splits. machine-based) to populate your splits.
- **Run a workout** — start a session from a split, track sets/reps/weight or - **Run a workout** — start a session from a split, then tap an exercise to run it
timed exercises, and mark exercises complete. 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 - **Progress tracking** — weight-progression charts per exercise across past
sessions. sessions.
- **Apple Watch companion** — starting a workout on the iPhone launches the watch - **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 app straight into it. The watch lists your in-progress workouts; pick one, pick an
work phases, count-down rests with final-three-second haptics and auto-advance, exercise, and run it as a paged flow: a **Ready?** lead-in, count-up work phases,
and **One More** / **Done** on the last set. Rest time is configurable; changes count-down rests with final-three-second haptics and auto-advance, and a **Finish**
sync back to the phone. 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 - **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. Drive, synced across devices and visible in the Files app. iCloud is required.
@@ -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<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)
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 + (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 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 {
VStack(spacing: 0) {
// Paged flow top half.
TabView(selection: $currentPage) {
ForEach(0..<totalPages, id: \.self) { index in
page(for: index)
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .bottom) {
if let dots = workDots {
WorkPhaseDots(model: dots)
.padding(.bottom, 8)
}
}
// Reserved space for a later iteration (set log, history, chart, ).
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.navigationTitle(log?.exerciseName ?? "")
.navigationBarTitleDisplayMode(.inline)
.onChange(of: currentPage) { _, newPage in
// 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 {
// Keep the screen lit while logging a mid-workout sleep is annoying.
UIApplication.shared.isIdleTimerDisabled = true
// 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
// pins page 0 itself, so skip the jump there.)
guard !didRestorePage else { return }
didRestorePage = true
if !showsReady {
jumpToResumePage()
Task { @MainActor in jumpToResumePage() }
}
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
}
}
/// 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() {
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<Content: View>: 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..<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 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()
}
}
@@ -24,14 +24,15 @@ struct WorkoutLogListView: View {
@State private var showingAddSheet = false @State private var showingAddSheet = false
@State private var logToDelete: WorkoutLogDocument? @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 /// Drives a programmatic push keyed by a log id, used for the freshly-added
/// `navigationDestination(item:)`. Rows navigate with plain destination-based /// exercise ( progress flow) and the Edit swipe ( detail/edit screen). Rows
/// `NavigationLink`s, so this is the only `navigationDestination` in the view /// navigate with plain destination-based `NavigationLink`s; these item-based
/// stacking a second one (e.g. a value-based `for: String.self`) made a single /// `navigationDestination`s are each bound to their own state, so they don't
/// row tap push twice. /// double-fire the way a value-based `navigationDestination(for:)` would.
private struct AddedLogRoute: Identifiable, Hashable { let id: String } private struct LogRoute: Identifiable, Hashable { let id: String }
init(workout: Workout) { init(workout: Workout) {
self.workout = workout self.workout = workout
@@ -68,7 +69,7 @@ struct WorkoutLogListView: View {
Section(header: Text(label)) { Section(header: Text(label)) {
ForEach(sortedLogs) { log in ForEach(sortedLogs) { log in
NavigationLink { NavigationLink {
ExerciseView(workout: workout, logID: log.id) ExerciseProgressView(doc: $doc, logID: log.id, onChange: { save() })
} label: { } label: {
CheckboxListItem( CheckboxListItem(
status: workoutStatus(log).checkboxStatus, status: workoutStatus(log).checkboxStatus,
@@ -93,6 +94,13 @@ struct WorkoutLogListView: View {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
Button {
logToEdit = LogRoute(id: log.id)
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
} }
} }
.onMove(perform: moveLog) .onMove(perform: moveLog)
@@ -101,8 +109,14 @@ struct WorkoutLogListView: View {
} }
} }
.navigationDestination(item: $addedLog) { route in .navigationDestination(item: $addedLog) { route in
// Seed with our working doc so the brand-new log is available before // A freshly-added exercise drops straight into its progress flow. The new
// the cache catches up. // 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) ExerciseView(workout: workout, logID: route.id, seedDoc: doc)
} }
.navigationTitle(doc.splitName ?? Split.unnamed) .navigationTitle(doc.splitName ?? Split.unnamed)
@@ -234,7 +248,7 @@ struct WorkoutLogListView: View {
save() save()
// Push the new exercise straight away. // 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. /// Recompute the workout's status/end from its logs, then persist.