Fix watch freeze on the progress flow; make Ready? always reachable
Tapping an in-progress exercise on the watch froze the app in an infinite SwiftUI re-render loop. WorkoutLogListView.body observed SwiftData two ways the iPhone list deliberately avoids: a @Query-bound Split, and a traversal of its `exercises` relationship during render (availableExercises). Reading an observed model's relationship inside body keeps the view perpetually subscribed and re-invalidating. Fix: fetch the split imperatively (not via @Query), gate the Add-Exercise affordances on the value-type doc.splitID, and evaluate availableExercises only from the picker sheet's closure. The list body now depends solely on the value-type working doc. Also remove the temporary on-screen diagnostics/PVDiag plumbing and restore PhaseTimerLayout and the dot-row animation that were dropped while debugging. Make the Ready? page always lead the exercise flow on both watch and iPhone (previously only for not-started exercises), so a resumed run can swipe back to it. A deliberate swipe back to Ready? resets the run; the transient paged TabView snap-to-0 on open is guarded by a settle gate plus an adjacent-swipe check, so an in-progress exercise lands on its set and is never reset on open. Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
This commit is contained in:
@@ -44,13 +44,12 @@ struct ExerciseProgressView: View {
|
||||
@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
|
||||
/// True when this run opened on a resumed set (in-progress) rather than the Ready
|
||||
/// page. Held in `@State` so it stays fixed for the life of the screen — the parent
|
||||
/// list re-inits this view whenever the workout file changes, and this must not flip
|
||||
/// mid-run. Such a run re-asserts its resume page after the first layout and ignores
|
||||
/// the transient TabView snap-to-0, so it isn't reset on open.
|
||||
@State private var startsResumed: Bool
|
||||
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void) {
|
||||
self._doc = doc
|
||||
@@ -61,20 +60,28 @@ struct ExerciseProgressView: View {
|
||||
let sets = max(1, log?.sets ?? 1)
|
||||
_setCount = State(initialValue: sets)
|
||||
|
||||
// The Ready page always leads the flow. A not-started run opens on it; an
|
||||
// in-progress run opens on its first unfinished set and re-asserts that page past
|
||||
// the TabView's snap-to-0 on first layout.
|
||||
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
|
||||
_showsReady = State(initialValue: notStarted)
|
||||
_startsResumed = State(initialValue: !notStarted)
|
||||
|
||||
let base = notStarted ? 1 : 0
|
||||
let base = 1
|
||||
// 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)
|
||||
let resume = base + completed * 2
|
||||
_currentPage = State(initialValue: notStarted ? 0 : resume)
|
||||
}
|
||||
|
||||
private var log: WorkoutLogDocument? {
|
||||
doc.logs.first { $0.id == logID }
|
||||
}
|
||||
|
||||
/// The **Ready?** page always leads the flow, so a resumed run can swipe back to it
|
||||
/// (which resets the exercise). (The watch additionally suppresses it for its
|
||||
/// screenshot host; the iPhone has no such host, so it's always shown.)
|
||||
private var showsReady: Bool { true }
|
||||
|
||||
/// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
|
||||
private var base: Int { showsReady ? 1 : 0 }
|
||||
|
||||
@@ -84,7 +91,7 @@ struct ExerciseProgressView: View {
|
||||
/// 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`).
|
||||
/// The first unfinished set's work page, used to resume an in-progress run.
|
||||
private var resumePage: Int {
|
||||
let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1)
|
||||
return base + completed * 2
|
||||
@@ -160,10 +167,14 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
.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 {
|
||||
.onChange(of: currentPage) { oldPage, newPage in
|
||||
// Ignore page changes until the initial resume settles, so the TabView's
|
||||
// transient snap-to-0 on first layout can't reset an in-progress run.
|
||||
guard didRestorePage else { return }
|
||||
// A deliberate swipe back from the first set to the Ready page wipes the run
|
||||
// (only the adjacent 1→0 swipe resets — a stray far jump never does); any
|
||||
// other page records forward progress.
|
||||
if showsReady && newPage == 0 && oldPage == base {
|
||||
resetExercise()
|
||||
} else {
|
||||
recordProgress(for: newPage)
|
||||
@@ -172,14 +183,20 @@ struct ExerciseProgressView: View {
|
||||
.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 {
|
||||
if startsResumed {
|
||||
// Resume on the first unfinished set. A paged TabView can settle on page 0
|
||||
// on first layout, so re-assert the resume page (now and once more after
|
||||
// this run loop) before honoring page changes — otherwise that snap would
|
||||
// land on, and reset at, the Ready page.
|
||||
jumpToResumePage()
|
||||
Task { @MainActor in jumpToResumePage() }
|
||||
Task { @MainActor in
|
||||
jumpToResumePage()
|
||||
didRestorePage = true
|
||||
}
|
||||
} else {
|
||||
// Not-started opens on the Ready page.
|
||||
didRestorePage = true
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
|
||||
Reference in New Issue
Block a user