Add per-split rest length and hands-free auto-advance flow

Two per-split settings, with the global Settings values as defaults:

- restSeconds: Int? — per-split rest, used between sets and (in flow) between
  exercises; nil falls back to the global default.
- autoAdvance: Bool? — flow mode: finishing an exercise rests, then opens the
  next one hands-free, all the way through the split.

Both are optional, snapshotted onto WorkoutDocument at the start sites (no live
split link), and not schema-bumped — same degradation pattern as activityType.

A thin RunFlowView wrapper (iOS + watch) owns the on-screen log and swaps it via
.id(currentLogID) on hand-off, so the per-exercise ExerciseProgressView stays
per-logID and untouched; the between-exercise rest reuses the existing .rest
countdown as the terminal page. The mirror reuses the per-logID live channel:
the wrapper suppresses the boundary .ended teardown so it follows across
exercises, and ContentView re-keys the cover on frame.logID — no sync-bridge
changes.

Morning Wake-Up ships as a flowing 45s-work / 15s-rest routine.

New Rest & Pacing section in the split editor exposes both controls.
This commit is contained in:
2026-07-09 15:18:13 -04:00
parent 0f5f11e5e2
commit 90deb582fe
18 changed files with 468 additions and 92 deletions
@@ -40,6 +40,14 @@ struct ExerciseProgressView: View {
let onLive: (LiveProgress) -> Void
let onLiveEnded: () -> Void
/// True when this run was auto-advanced into from the previous exercise (flow mode).
/// It suppresses the Ready page and begins the exercise immediately on appear.
let enteredViaFlow: Bool
/// Invoked at the terminal rest's end in flow mode to hand off to the next exercise;
/// the parent `RunFlowView` swaps the presented log. Nil for a non-advancing run.
var onAdvance: ((String) -> Void)? = nil
/// The latest live-run frame the *phone* sent for this run, to follow when it drives a
/// transition (ephemeral; nil when the phone isn't driving). Applying it jumps our page
/// without re-broadcasting or re-recording the originating device owns the durable write.
@@ -101,12 +109,14 @@ struct ExerciseProgressView: View {
/// also suppresses the Ready page so the index is a plain work/rest cycle offset.
private let debugInitialPage: Int?
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, debugInitialPage: Int? = nil) {
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, enteredViaFlow: Bool = false, onAdvance: ((String) -> Void)? = nil, debugInitialPage: Int? = nil) {
self._doc = doc
self.logID = logID
self.onChange = onChange
self.onLive = onLive
self.onLiveEnded = onLiveEnded
self.enteredViaFlow = enteredViaFlow
self.onAdvance = onAdvance
self.incomingFrame = incomingFrame
self.debugInitialPage = debugInitialPage
@@ -116,10 +126,11 @@ struct ExerciseProgressView: View {
_knownStateIndex = State(initialValue: max(0, log?.currentStateIndex ?? 0))
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
// 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
// The Ready page always leads the flow (except in the screenshot host, or a
// flow-advanced entry which begins immediately). 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 && !enteredViaFlow
_startsResumed = State(initialValue: ready && !notStarted)
_startsCompleted = State(initialValue: ready && log?.status == WorkoutStatus.completed.rawValue)
@@ -135,10 +146,30 @@ struct ExerciseProgressView: View {
}
/// 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 }
/// (which resets the exercise). Suppressed for the screenshot host (which pins an
/// explicit page) and for a flow-advanced entry (which begins immediately). Derived
/// from immutable inputs, so it stays stable for the life of the screen the
/// page-index math below depends on it.
private var showsReady: Bool { debugInitialPage == nil && !enteredViaFlow }
/// This split runs as a continuous flow finishing one exercise rests, then
/// auto-advances into the next rather than returning to the list per exercise.
private var autoAdvance: Bool { doc.autoAdvance == true }
/// Rest length for this run: the split's per-split override, else the global default.
/// Governs both between-set rests and (in flow mode) the between-exercise rest.
private var effectiveRest: Int { max(1, doc.restSeconds ?? restSeconds) }
/// The next not-yet-resolved exercise after this one, in plan order the hand-off
/// target for flow mode. Nil when this is the last unfinished exercise ( the run ends).
private var nextUnfinishedLogID: String? {
let sorted = doc.logs.sorted { $0.order < $1.order }
guard let idx = sorted.firstIndex(where: { $0.id == logID }) else { return nil }
return sorted[(idx + 1)...].first { log in
let status = WorkoutStatus(rawValue: log.status) ?? .notStarted
return status == .notStarted || status == .inProgress
}?.id
}
/// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
private var base: Int { showsReady ? 1 : 0 }
@@ -292,6 +323,12 @@ struct ExerciseProgressView: View {
jumpToResumePage()
finishRestore()
}
} else if enteredViaFlow {
// Auto-advanced into this exercise (flow mode): no Ready tap begin the
// exercise and broadcast (via finishRestore) so a mirroring phone follows
// across the exercise boundary.
beginExercise()
finishRestore()
} else {
// Not-started opens on the Ready page; the screenshot host pins its own.
finishRestore()
@@ -409,6 +446,12 @@ struct ExerciseProgressView: View {
}
let cycleIndex = page - base
if cycleIndex == cycleCount {
// In flow mode with a next exercise, the terminal page is a between-exercise
// rest (mirrored as a `.rest` frame), not the Finish page.
if autoAdvance && nextUnfinishedLogID != nil {
return frame(.rest, setIndex: max(0, setCount - 1),
end: now.addingTimeInterval(Double(effectiveRest)))
}
return frame(.finish, setIndex: max(0, setCount - 1),
end: now.addingTimeInterval(Double(max(1, doneCountdownSeconds))))
} else if cycleIndex.isMultiple(of: 2) {
@@ -417,7 +460,7 @@ struct ExerciseProgressView: View {
return frame(.work, setIndex: set, end: end)
} else {
let set = (cycleIndex - 1) / 2 // the rest follows this set
return frame(.rest, setIndex: set, end: now.addingTimeInterval(Double(max(1, restSeconds))))
return frame(.rest, setIndex: set, end: now.addingTimeInterval(Double(effectiveRest)))
}
}
@@ -443,13 +486,35 @@ struct ExerciseProgressView: View {
} 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,
anchorEnd: anchorEnd
)
if autoAdvance, nextUnfinishedLogID != nil {
// Flow mode: the terminal page is a between-exercise rest that
// completes this exercise and hands off to the next when it ends.
CountdownPhaseView(
header: "Rest",
tint: .restTimer,
seconds: effectiveRest,
isActive: isActive,
anchorStart: anchorStart,
anchorEnd: anchorEnd
) { _ in
completeExercise()
// Re-resolve at fire time the next exercise may have been
// finished elsewhere while we rested. None left end the run.
if let next = nextUnfinishedLogID {
onAdvance?(next)
} else {
dismiss()
}
}
} else {
// Finish page confirm Done (auto-fires) or add One More.
FinishPhaseView(
isActive: isActive,
onDone: { completeExercise(); dismiss() },
onOneMore: addSet,
anchorEnd: anchorEnd
)
}
} else if cycleIndex.isMultiple(of: 2) {
let setNumber = cycleIndex / 2 + 1
if isDuration {
@@ -480,7 +545,7 @@ struct ExerciseProgressView: View {
CountdownPhaseView(
header: "Rest",
tint: .restTimer,
seconds: restSeconds,
seconds: effectiveRest,
isActive: isActive,
anchorStart: anchorStart,
anchorEnd: anchorEnd
@@ -0,0 +1,75 @@
//
// RunFlowView.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
/// Thin coordinator that lets a single pushed run walk across exercises in a flow-mode
/// split (`autoAdvance`). It owns which log is on screen and swaps it when the running
/// `ExerciseProgressView` finishes an exercise; `.id(currentLogID)` rebuilds the child
/// fresh for each exercise, so the delicate per-exercise run/mirror machinery stays
/// exactly as it is. For a non-flow split it's transparent one exercise, unchanged.
struct RunFlowView: View {
@Environment(WatchConnectivityBridge.self) private var bridge
@Binding var doc: WorkoutDocument
let startLogID: String
let onChange: () -> Void
let onLive: (LiveProgress) -> Void
/// Send the "stop mirroring this run" signal for a given log parameterized because
/// the run's current log advances; the leaving exercise's id is what ends.
let sendLiveEnded: (String) -> Void
@State private var currentLogID: String
/// True once we've auto-advanced at least once the current exercise then skips its
/// Ready page and begins on appear. The first exercise (manual entry) keeps Ready.
@State private var enteredViaFlow = false
/// Set across an exercise hand-off so the leaving exercise's teardown doesn't tear the
/// mirror down the run is continuing, not ending.
@State private var advancing = false
init(doc: Binding<WorkoutDocument>, startLogID: String, onChange: @escaping () -> Void,
onLive: @escaping (LiveProgress) -> Void, sendLiveEnded: @escaping (String) -> Void) {
self._doc = doc
self.startLogID = startLogID
self.onChange = onChange
self.onLive = onLive
self.sendLiveEnded = sendLiveEnded
_currentLogID = State(initialValue: startLogID)
}
var body: some View {
// The ZStack is a stable identity (its lifecycle fires once for the whole run),
// while the child re-identifies per exercise via `.id(currentLogID)`.
ZStack {
ExerciseProgressView(
doc: $doc,
logID: currentLogID,
onChange: onChange,
onLive: onLive,
onLiveEnded: { if !advancing { sendLiveEnded(currentLogID) } },
incomingFrame: bridge.liveIncoming.flatMap { $0.logID == currentLogID ? $0 : nil },
enteredViaFlow: enteredViaFlow,
onAdvance: advance(to:)
)
.id(currentLogID)
}
.onAppear { bridge.navigatedRunID = currentLogID }
.onDisappear { if bridge.navigatedRunID == currentLogID { bridge.navigatedRunID = nil } }
}
/// Hand off to the next exercise. Suppresses the leaving exercise's live-ended teardown
/// so the mirror follows across the boundary, moves `navigatedRunID` forward so this
/// device doesn't mirror its own next exercise, then swaps the presented log.
private func advance(to next: String) {
advancing = true
bridge.navigatedRunID = next
enteredViaFlow = true
currentLogID = next
// Release the guard after the swap settles (old view torn down, new one begun).
Task { @MainActor in advancing = false }
}
}
@@ -114,18 +114,17 @@ struct WorkoutLogListView: View {
}
}
.navigationDestination(item: $selectedLogID) { logID in
ExerciseProgressView(
// RunFlowView hosts the run and, in a flow-mode split, auto-advances across
// exercises. It owns the live-run mirror wiring (incoming-frame filter,
// `navigatedRunID`, and the parameterized live-ended signal) since the on-screen
// log advances during a flowing run.
RunFlowView(
doc: $doc,
logID: logID,
startLogID: logID,
onChange: { bridge.update(workout: doc) },
onLive: { bridge.sendLiveProgress($0) },
onLiveEnded: { bridge.sendLiveEnded(workoutID: doc.id, logID: logID) },
// Follow the phone when it drives this same run from its mirror.
incomingFrame: bridge.liveIncoming.flatMap { $0.logID == logID ? $0 : nil }
sendLiveEnded: { lid in bridge.sendLiveEnded(workoutID: doc.id, logID: lid) }
)
// We're driving this run inline suppress the follower cover for it.
.onAppear { bridge.navigatedRunID = logID }
.onDisappear { if bridge.navigatedRunID == logID { bridge.navigatedRunID = nil } }
}
.sheet(isPresented: $showingExercisePicker) {
ExercisePickerView(exercises: availableExercises) { exercise in