Make watch HIIT progress monotonic and resume to next unfinished set

Completing a work phase advances the set count to the iPhone, and a finished
set is never un-counted — a transient paged-TabView snap to page 0 can no longer
overwrite progress with 0. Reopening an exercise now jumps to the first
unfinished set's work page (re-asserted after first layout) instead of starting
back at set 1.

Claude-Session: https://claude.ai/code/session_018gg69MaUetDNzWzBXisfMV
This commit is contained in:
2026-06-19 16:41:41 -04:00
parent d5915a9552
commit 8c6e798aba
2 changed files with 39 additions and 5 deletions
+4
View File
@@ -12,6 +12,10 @@ All notable changes to this project are documented here.
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.
- 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
@@ -33,6 +33,7 @@ struct ExerciseProgressView: View {
@State private var setCount: Int
@State private var currentPage: Int
@State private var showingCancelConfirm = false
@State private var didRestorePage = false
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void) {
self._doc = doc
@@ -54,6 +55,13 @@ struct ExerciseProgressView: View {
/// Work, Rest, , Work N sets + (N1) rests = 2N 1 pages.
private var totalPages: Int { setCount * 2 - 1 }
/// The first unfinished set's work page (clamped to the last set). Resuming an
/// exercise opens here, skipping any completed work/rest pairs.
private var resumePage: Int {
let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1)
return completed * 2
}
private var detail: String {
guard let log else { return "" }
if LoadType(rawValue: log.loadType) == .duration {
@@ -86,6 +94,24 @@ struct ExerciseProgressView: View {
.onChange(of: currentPage) { _, newPage in
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.
guard !didRestorePage else { return }
didRestorePage = true
jumpToResumePage()
Task { @MainActor in jumpToResumePage() }
}
}
/// 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
@@ -140,17 +166,21 @@ struct ExerciseProgressView: View {
/// Page index completed-set count: Work(0)0, Rest(1)1, Work(2)1,
/// i.e. `(pageIndex + 1) / 2`. Reaching set N as *completed* only happens via Done.
///
/// Progress is **monotonic**: completing a work phase advances the count (and
/// forwards it to the phone), but swiping back or a transient TabView snap to
/// page 0 never un-counts a finished set.
private func recordProgress(for pageIndex: Int) {
let completedSets = min((pageIndex + 1) / 2, setCount)
let reached = min((pageIndex + 1) / 2, setCount)
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
guard completedSets != doc.logs[i].currentStateIndex else { return }
guard reached > doc.logs[i].currentStateIndex else { return }
doc.logs[i].currentStateIndex = completedSets
if completedSets >= setCount {
doc.logs[i].currentStateIndex = reached
if reached >= setCount {
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
} else if completedSets > 0 {
} else {
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
}