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:
@@ -8,18 +8,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WatchKit
|
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:
|
/// Runs a single exercise as a horizontally-paged flow, mirroring the iPhone's:
|
||||||
///
|
///
|
||||||
/// [Ready] → Work₁ → Rest₁ → Work₂ → … → Workₙ → Finish
|
/// [Ready] → Work₁ → Rest₁ → Work₂ → … → Workₙ → Finish
|
||||||
@@ -54,11 +42,10 @@ struct ExerciseProgressView: View {
|
|||||||
@State private var showingCancelConfirm = false
|
@State private var showingCancelConfirm = false
|
||||||
@State private var didRestorePage = false
|
@State private var didRestorePage = false
|
||||||
|
|
||||||
/// True when this run opens on the lead-in **Ready?** page (the exercise hadn't been
|
/// True when this run opened on a resumed set (in-progress) rather than the Ready
|
||||||
/// started). A resumed exercise — or the screenshot host — skips it. Held in `@State`
|
/// page. Such a run re-asserts its resume page after the first layout and ignores the
|
||||||
/// so it stays fixed for the life of the screen (same reasoning as the iPhone view),
|
/// transient TabView snap-to-0, so it isn't reset on open.
|
||||||
/// keeping the page-index math below stable.
|
@State private var startsResumed: Bool
|
||||||
@State private var showsReady: Bool
|
|
||||||
|
|
||||||
/// Forces the starting page (used only by the DEBUG screenshot host). When set it
|
/// 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.
|
/// 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)
|
_setCount = State(initialValue: sets)
|
||||||
|
|
||||||
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
|
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
|
||||||
let ready = debugInitialPage == nil && notStarted
|
// The Ready page always leads the flow (except in the screenshot host). A
|
||||||
_showsReady = State(initialValue: ready)
|
// 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
|
let base = ready ? 1 : 0
|
||||||
// Resume on the first unfinished set's work page (clamped to the last set).
|
// 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 completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1)
|
||||||
let resume = ready ? 0 : base + completed * 2
|
let resume = base + completed * 2
|
||||||
_currentPage = State(initialValue: debugInitialPage ?? resume)
|
_currentPage = State(initialValue: debugInitialPage ?? (notStarted ? 0 : resume))
|
||||||
PVDiag.initCount += 1
|
|
||||||
PVDiag.add("init: ready=\(ready) page=\(debugInitialPage ?? resume) sets=\(sets) duration=\(LoadType(rawValue: log?.loadType ?? -1) == .duration)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var log: WorkoutLogDocument? {
|
private var log: WorkoutLogDocument? {
|
||||||
doc.logs.first { $0.id == logID }
|
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`.
|
/// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
|
||||||
private var base: Int { showsReady ? 1 : 0 }
|
private var base: Int { showsReady ? 1 : 0 }
|
||||||
|
|
||||||
@@ -100,7 +94,7 @@ struct ExerciseProgressView: View {
|
|||||||
/// Ready (`base`) + cycle (`2N − 1`) + Finish (`1`).
|
/// Ready (`base`) + cycle (`2N − 1`) + Finish (`1`).
|
||||||
private var totalPages: Int { base + cycleCount + 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 {
|
private var resumePage: Int {
|
||||||
let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1)
|
let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1)
|
||||||
return base + completed * 2
|
return base + completed * 2
|
||||||
@@ -166,20 +160,6 @@ struct ExerciseProgressView: View {
|
|||||||
.padding(.bottom, 2)
|
.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 {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button {
|
Button {
|
||||||
@@ -193,25 +173,34 @@ struct ExerciseProgressView: View {
|
|||||||
Button("Cancel Exercise", role: .destructive) { dismiss() }
|
Button("Cancel Exercise", role: .destructive) { dismiss() }
|
||||||
Button("Continue", role: .cancel) { }
|
Button("Continue", role: .cancel) { }
|
||||||
}
|
}
|
||||||
.onChange(of: currentPage) { _, newPage in
|
.onChange(of: currentPage) { oldPage, newPage in
|
||||||
PVDiag.add("currentPage -> \(newPage)")
|
// Ignore page changes until the initial resume settles, so the TabView's
|
||||||
// Swiping all the way back to the Ready page wipes the run; any other page
|
// transient snap-to-0 on first layout can't reset an in-progress run.
|
||||||
// records forward progress.
|
guard didRestorePage else { return }
|
||||||
if showsReady && newPage == 0 {
|
// 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()
|
resetExercise()
|
||||||
} else {
|
} else {
|
||||||
recordProgress(for: newPage)
|
recordProgress(for: newPage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.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 }
|
guard !didRestorePage else { return }
|
||||||
didRestorePage = true
|
if startsResumed {
|
||||||
if !showsReady && debugInitialPage == nil {
|
// 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()
|
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.
|
/// Flip a not-yet-started exercise to in-progress the moment the user taps Start.
|
||||||
private func beginExercise() {
|
private func beginExercise() {
|
||||||
PVDiag.add("beginExercise -> onChange (bridge.update)")
|
|
||||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
guard doc.logs[i].status == WorkoutStatus.notStarted.rawValue else { return }
|
guard doc.logs[i].status == WorkoutStatus.notStarted.rawValue else { return }
|
||||||
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
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 let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
guard reached > doc.logs[i].currentStateIndex 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].currentStateIndex = reached
|
||||||
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
||||||
doc.logs[i].completed = false
|
doc.logs[i].completed = false
|
||||||
@@ -355,7 +342,6 @@ struct ExerciseProgressView: View {
|
|||||||
guard log.currentStateIndex != 0
|
guard log.currentStateIndex != 0
|
||||||
|| log.status != WorkoutStatus.notStarted.rawValue
|
|| log.status != WorkoutStatus.notStarted.rawValue
|
||||||
|| log.completed else { return }
|
|| log.completed else { return }
|
||||||
PVDiag.add("resetExercise -> onChange (bridge.update)")
|
|
||||||
doc.logs[i].currentStateIndex = 0
|
doc.logs[i].currentStateIndex = 0
|
||||||
doc.logs[i].status = WorkoutStatus.notStarted.rawValue
|
doc.logs[i].status = WorkoutStatus.notStarted.rawValue
|
||||||
doc.logs[i].completed = false
|
doc.logs[i].completed = false
|
||||||
@@ -580,7 +566,6 @@ private struct WorkPhaseView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func restart() {
|
private func restart() {
|
||||||
PVDiag.add("work.restart set=\(setNumber) (beep)")
|
|
||||||
startDate = Date()
|
startDate = Date()
|
||||||
WorkoutHaptic.start.play()
|
WorkoutHaptic.start.play()
|
||||||
}
|
}
|
||||||
@@ -624,7 +609,6 @@ private struct CountdownPhaseView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func start() {
|
private func start() {
|
||||||
PVDiag.add("countdown.start '\(header)' seconds=\(seconds) (beep)")
|
|
||||||
startDate = Date()
|
startDate = Date()
|
||||||
endDate = startDate.addingTimeInterval(Double(max(1, seconds)))
|
endDate = startDate.addingTimeInterval(Double(max(1, seconds)))
|
||||||
lastPingSecond = Int.max
|
lastPingSecond = Int.max
|
||||||
@@ -640,7 +624,6 @@ private struct CountdownPhaseView: View {
|
|||||||
if remaining <= 0 {
|
if remaining <= 0 {
|
||||||
// Time's up — final cue and slide on. If the wrist was down the timer may have
|
// 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.
|
// stalled; this then fires on the first tick once the app gets runtime again.
|
||||||
PVDiag.add("countdown.finished '\(header)' -> advance")
|
|
||||||
didFinish = true
|
didFinish = true
|
||||||
WorkoutHaptic.stop.play()
|
WorkoutHaptic.stop.play()
|
||||||
onFinished()
|
onFinished()
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ import SwiftData
|
|||||||
|
|
||||||
struct WorkoutLogListView: View {
|
struct WorkoutLogListView: View {
|
||||||
@Environment(WatchConnectivityBridge.self) private var bridge
|
@Environment(WatchConnectivityBridge.self) private var bridge
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
/// 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]
|
|
||||||
|
|
||||||
/// Working copy of the workout. We drive the UI from this and mutate it on
|
/// 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
|
/// every edit (then forward through the bridge) to avoid the read-after-write
|
||||||
@@ -25,15 +22,19 @@ struct WorkoutLogListView: View {
|
|||||||
|
|
||||||
init(workout: Workout) {
|
init(workout: Workout) {
|
||||||
_doc = State(initialValue: WorkoutDocument(from: workout))
|
_doc = State(initialValue: WorkoutDocument(from: workout))
|
||||||
if let splitID = workout.splitID {
|
|
||||||
_matchingSplits = Query(filter: #Predicate<Split> { $0.id == splitID })
|
|
||||||
} else {
|
|
||||||
// No source split: never match anything.
|
|
||||||
_matchingSplits = Query(filter: #Predicate<Split> { _ 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] {
|
private var sortedLogs: [WorkoutLogDocument] {
|
||||||
doc.logs.sorted { $0.order < $1.order }
|
doc.logs.sorted { $0.order < $1.order }
|
||||||
@@ -58,7 +59,7 @@ struct WorkoutLogListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availableExercises.isEmpty {
|
if doc.splitID != nil {
|
||||||
Section {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
showingExercisePicker = true
|
showingExercisePicker = true
|
||||||
@@ -77,7 +78,7 @@ struct WorkoutLogListView: View {
|
|||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No Exercises",
|
"No Exercises",
|
||||||
systemImage: "figure.strengthtraining.traditional",
|
systemImage: "figure.strengthtraining.traditional",
|
||||||
description: Text(availableExercises.isEmpty
|
description: Text(doc.splitID == nil
|
||||||
? "No exercises in this workout."
|
? "No exercises in this workout."
|
||||||
: "Tap + to add exercises.")
|
: "Tap + to add exercises.")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,13 +44,12 @@ struct ExerciseProgressView: View {
|
|||||||
@State private var currentPage: Int
|
@State private var currentPage: Int
|
||||||
@State private var didRestorePage = false
|
@State private var didRestorePage = false
|
||||||
|
|
||||||
/// True when this run opens on the lead-in **Ready?** page (the exercise hadn't
|
/// True when this run opened on a resumed set (in-progress) rather than the Ready
|
||||||
/// been started yet). Held in `@State` so it stays fixed for the life of the screen:
|
/// page. Held in `@State` so it stays fixed for the life of the screen — the parent
|
||||||
/// the parent list rebuilds (re-inits) this view whenever the workout file changes,
|
/// list re-inits this view whenever the workout file changes, and this must not flip
|
||||||
/// and once Start marks the exercise in-progress a recomputed `let` would flip to
|
/// mid-run. Such a run re-asserts its resume page after the first layout and ignores
|
||||||
/// `false` mid-run — dropping `base` from 1 to 0 and remapping the current page onto
|
/// the transient TabView snap-to-0, so it isn't reset on open.
|
||||||
/// the wrong phase. Frozen here, all the page-index math below stays stable.
|
@State private var startsResumed: Bool
|
||||||
@State private var showsReady: Bool
|
|
||||||
|
|
||||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void) {
|
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void) {
|
||||||
self._doc = doc
|
self._doc = doc
|
||||||
@@ -61,20 +60,28 @@ struct ExerciseProgressView: View {
|
|||||||
let sets = max(1, log?.sets ?? 1)
|
let sets = max(1, log?.sets ?? 1)
|
||||||
_setCount = State(initialValue: sets)
|
_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
|
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).
|
// 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 completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1)
|
||||||
let resume = notStarted ? 0 : base + completed * 2
|
let resume = base + completed * 2
|
||||||
_currentPage = State(initialValue: resume)
|
_currentPage = State(initialValue: notStarted ? 0 : resume)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var log: WorkoutLogDocument? {
|
private var log: WorkoutLogDocument? {
|
||||||
doc.logs.first { $0.id == logID }
|
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`.
|
/// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
|
||||||
private var base: Int { showsReady ? 1 : 0 }
|
private var base: Int { showsReady ? 1 : 0 }
|
||||||
|
|
||||||
@@ -84,7 +91,7 @@ struct ExerciseProgressView: View {
|
|||||||
/// Ready (`base`) + cycle (`2N − 1`) + Finish (`1`).
|
/// Ready (`base`) + cycle (`2N − 1`) + Finish (`1`).
|
||||||
private var totalPages: Int { base + cycleCount + 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 {
|
private var resumePage: Int {
|
||||||
let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1)
|
let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1)
|
||||||
return base + completed * 2
|
return base + completed * 2
|
||||||
@@ -160,10 +167,14 @@ struct ExerciseProgressView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(log?.exerciseName ?? "")
|
.navigationTitle(log?.exerciseName ?? "")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onChange(of: currentPage) { _, newPage in
|
.onChange(of: currentPage) { oldPage, newPage in
|
||||||
// Swiping all the way back to the Ready page wipes the run; any other page
|
// Ignore page changes until the initial resume settles, so the TabView's
|
||||||
// records forward progress.
|
// transient snap-to-0 on first layout can't reset an in-progress run.
|
||||||
if showsReady && newPage == 0 {
|
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()
|
resetExercise()
|
||||||
} else {
|
} else {
|
||||||
recordProgress(for: newPage)
|
recordProgress(for: newPage)
|
||||||
@@ -172,14 +183,20 @@ struct ExerciseProgressView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
// Keep the screen lit while logging — a mid-workout sleep is annoying.
|
// Keep the screen lit while logging — a mid-workout sleep is annoying.
|
||||||
UIApplication.shared.isIdleTimerDisabled = true
|
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 }
|
guard !didRestorePage else { return }
|
||||||
didRestorePage = true
|
if startsResumed {
|
||||||
if !showsReady {
|
// 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()
|
jumpToResumePage()
|
||||||
Task { @MainActor in jumpToResumePage() }
|
Task { @MainActor in
|
||||||
|
jumpToResumePage()
|
||||||
|
didRestorePage = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not-started opens on the Ready page.
|
||||||
|
didRestorePage = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
|
|||||||
Reference in New Issue
Block a user