diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index 637c0d2..22d8e29 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -8,18 +8,6 @@ import SwiftUI import WatchKit -// DIAGNOSTIC (temporary): records recent lifecycle/mutation events so the on-screen -// overlay can show what ran right before a freeze. Remove once the hang is solved. -enum PVDiag { - nonisolated(unsafe) static var initCount = 0 - nonisolated(unsafe) static var lines: [String] = [] - static func add(_ s: String) { - print("⌚️PV \(s)") - lines.append(s) - if lines.count > 7 { lines.removeFirst(lines.count - 7) } - } -} - /// Runs a single exercise as a horizontally-paged flow, mirroring the iPhone's: /// /// [Ready] → Work₁ → Rest₁ → Work₂ → … → Workₙ → Finish @@ -54,11 +42,10 @@ struct ExerciseProgressView: View { @State private var showingCancelConfirm = false @State private var didRestorePage = false - /// True when this run opens on the lead-in **Ready?** page (the exercise hadn't been - /// started). A resumed exercise — or the screenshot host — skips it. Held in `@State` - /// so it stays fixed for the life of the screen (same reasoning as the iPhone view), - /// keeping the page-index math below stable. - @State private var showsReady: Bool + /// True when this run opened on a resumed set (in-progress) rather than the Ready + /// page. 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 /// Forces the starting page (used only by the DEBUG screenshot host). When set it /// also suppresses the Ready page so the index is a plain work/rest cycle offset. @@ -75,22 +62,29 @@ struct ExerciseProgressView: View { _setCount = State(initialValue: sets) let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue - let ready = debugInitialPage == nil && notStarted - _showsReady = State(initialValue: ready) + // The Ready page always leads the flow (except in the screenshot host). 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 ready = debugInitialPage == nil + _startsResumed = State(initialValue: ready && !notStarted) let base = ready ? 1 : 0 // Resume on the first unfinished set's work page (clamped to the last set). let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1) - let resume = ready ? 0 : base + completed * 2 - _currentPage = State(initialValue: debugInitialPage ?? resume) - PVDiag.initCount += 1 - PVDiag.add("init: ready=\(ready) page=\(debugInitialPage ?? resume) sets=\(sets) duration=\(LoadType(rawValue: log?.loadType ?? -1) == .duration)") + let resume = base + completed * 2 + _currentPage = State(initialValue: debugInitialPage ?? (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). Suppressed only for the screenshot host, which pins an + /// explicit page. Derived from the immutable `debugInitialPage`, so it stays stable for + /// the life of the screen — the page-index math below depends on it. + private var showsReady: Bool { debugInitialPage == nil } + /// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`. private var base: Int { showsReady ? 1 : 0 } @@ -100,7 +94,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 @@ -166,20 +160,6 @@ struct ExerciseProgressView: View { .padding(.bottom, 2) } } - // DIAGNOSTIC overlay (temporary): shows recent events; readable off a frozen screen. - .overlay(alignment: .top) { - VStack(alignment: .leading, spacing: 0) { - Text("init#\(PVDiag.initCount)").bold() - ForEach(Array(PVDiag.lines.enumerated()), id: \.offset) { _, line in - Text(line) - } - } - .font(.system(size: 8, design: .monospaced)) - .foregroundStyle(.green) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.black.opacity(0.65)) - .allowsHitTesting(false) - } .toolbar { ToolbarItem(placement: .cancellationAction) { Button { @@ -193,25 +173,34 @@ struct ExerciseProgressView: View { Button("Cancel Exercise", role: .destructive) { dismiss() } Button("Continue", role: .cancel) { } } - .onChange(of: currentPage) { _, newPage in - PVDiag.add("currentPage -> \(newPage)") - // Swiping all the way back to the Ready page wipes the run; any other page - // records forward progress. - if showsReady && newPage == 0 { + .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) } } .onAppear { - // Jump to the first unfinished set. A paged TabView can settle on page 0 on - // first layout, so re-assert once more after this run loop. (The Ready page - // and the screenshot host both pin an explicit page, so skip the jump there.) guard !didRestorePage else { return } - didRestorePage = true - if !showsReady && debugInitialPage == nil { + 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; the screenshot host pins its own. + didRestorePage = true } } } @@ -293,7 +282,6 @@ struct ExerciseProgressView: View { /// Flip a not-yet-started exercise to in-progress the moment the user taps Start. private func beginExercise() { - PVDiag.add("beginExercise -> onChange (bridge.update)") guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } guard doc.logs[i].status == WorkoutStatus.notStarted.rawValue else { return } doc.logs[i].status = WorkoutStatus.inProgress.rawValue @@ -337,7 +325,6 @@ struct ExerciseProgressView: View { guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } guard reached > doc.logs[i].currentStateIndex else { return } - PVDiag.add("recordProgress reached=\(reached) -> onChange (bridge.update)") doc.logs[i].currentStateIndex = reached doc.logs[i].status = WorkoutStatus.inProgress.rawValue doc.logs[i].completed = false @@ -355,7 +342,6 @@ struct ExerciseProgressView: View { guard log.currentStateIndex != 0 || log.status != WorkoutStatus.notStarted.rawValue || log.completed else { return } - PVDiag.add("resetExercise -> onChange (bridge.update)") doc.logs[i].currentStateIndex = 0 doc.logs[i].status = WorkoutStatus.notStarted.rawValue doc.logs[i].completed = false @@ -580,7 +566,6 @@ private struct WorkPhaseView: View { } private func restart() { - PVDiag.add("work.restart set=\(setNumber) (beep)") startDate = Date() WorkoutHaptic.start.play() } @@ -624,7 +609,6 @@ private struct CountdownPhaseView: View { } private func start() { - PVDiag.add("countdown.start '\(header)' seconds=\(seconds) (beep)") startDate = Date() endDate = startDate.addingTimeInterval(Double(max(1, seconds))) lastPingSecond = Int.max @@ -640,7 +624,6 @@ private struct CountdownPhaseView: View { if remaining <= 0 { // Time's up — final cue and slide on. If the wrist was down the timer may have // stalled; this then fires on the first tick once the app gets runtime again. - PVDiag.add("countdown.finished '\(header)' -> advance") didFinish = true WorkoutHaptic.stop.play() onFinished() diff --git a/Workouts Watch App/Views/WorkoutLogListView.swift b/Workouts Watch App/Views/WorkoutLogListView.swift index 105de6d..259a1e5 100644 --- a/Workouts Watch App/Views/WorkoutLogListView.swift +++ b/Workouts Watch App/Views/WorkoutLogListView.swift @@ -10,10 +10,7 @@ import SwiftData struct WorkoutLogListView: View { @Environment(WatchConnectivityBridge.self) private var bridge - - /// The split this workout came from (read-only on the watch), used to offer - /// additional exercises that aren't logged yet. - @Query private var matchingSplits: [Split] + @Environment(\.modelContext) private var modelContext /// Working copy of the workout. We drive the UI from this and mutate it on /// every edit (then forward through the bridge) to avoid the read-after-write @@ -25,15 +22,19 @@ struct WorkoutLogListView: View { init(workout: Workout) { _doc = State(initialValue: WorkoutDocument(from: workout)) - if let splitID = workout.splitID { - _matchingSplits = Query(filter: #Predicate { $0.id == splitID }) - } else { - // No source split: never match anything. - _matchingSplits = Query(filter: #Predicate { _ in false }) - } } - private var split: Split? { matchingSplits.first } + /// The split this workout came from (read-only on the watch), used to offer + /// additional exercises that aren't logged yet. Fetched imperatively — *not* via + /// `@Query` — so the list body never observes the live `Split` or traverses its + /// `exercises` relationship during a render. Doing so (a `@Query`-observed model + /// whose to-many relationship is read in `body`) drove a SwiftData re-render loop + /// that hung the watch. `availableExercises` is therefore only ever evaluated from + /// the picker sheet's closure, not from `body`. + private var split: Split? { + guard let splitID = doc.splitID else { return nil } + return CacheMapper.fetchSplit(id: splitID, in: modelContext) + } private var sortedLogs: [WorkoutLogDocument] { doc.logs.sorted { $0.order < $1.order } @@ -58,7 +59,7 @@ struct WorkoutLogListView: View { } } - if !availableExercises.isEmpty { + if doc.splitID != nil { Section { Button { showingExercisePicker = true @@ -77,7 +78,7 @@ struct WorkoutLogListView: View { ContentUnavailableView( "No Exercises", systemImage: "figure.strengthtraining.traditional", - description: Text(availableExercises.isEmpty + description: Text(doc.splitID == nil ? "No exercises in this workout." : "Tap + to add exercises.") ) diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index f57c950..fe0a5b7 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -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, 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 {