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:
+21
-61
@@ -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.
|
|
||||||
|
|||||||
@@ -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 + (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 {
|
||||||
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user