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
@@ -38,6 +38,16 @@ struct ExerciseProgressView: View {
let logID: String
let onChange: () -> 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 the
/// whole point of a hands-free flowing split.
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
/// (the terminal page is then the normal Finish page that dismisses).
var onAdvance: ((String) -> Void)? = nil
/// Broadcasts the current flow position so the watch (if it has this run open) can follow
/// it live (ephemeral; see `LiveProgress`). `onLiveEnded` fires when we leave the flow.
/// Only *human* transitions are broadcast an auto-advance (rest/timed-work end) isn't,
@@ -135,7 +145,7 @@ struct ExerciseProgressView: View {
/// `startsCompleted`.
@State private var startsSkipped: Bool
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, speechAnnouncer: SpeechAnnouncer? = nil) {
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, speechAnnouncer: SpeechAnnouncer? = nil, enteredViaFlow: Bool = false, onAdvance: ((String) -> Void)? = nil) {
self._doc = doc
self.logID = logID
self.onChange = onChange
@@ -145,6 +155,8 @@ struct ExerciseProgressView: View {
self.onActivityEnded = onActivityEnded
self.incomingFrame = incomingFrame
self.speechAnnouncer = speechAnnouncer
self.enteredViaFlow = enteredViaFlow
self.onAdvance = onAdvance
let log = doc.wrappedValue.logs.first { $0.id == logID }
let sets = max(1, log?.sets ?? 1)
@@ -159,7 +171,8 @@ struct ExerciseProgressView: View {
_startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue)
_startsSkipped = State(initialValue: log?.status == WorkoutStatus.skipped.rawValue)
let base = 1
// A flow-advanced entry has no Ready page (base 0), so page 0 is its first work set.
let base = enteredViaFlow ? 0 : 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 = base + completed * 2
@@ -170,10 +183,29 @@ struct ExerciseProgressView: View {
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 }
/// The **Ready?** page leads the flow so a resumed run can swipe back to it (which
/// resets the exercise). A flow-advanced entry skips it the exercise begins on
/// appear, hands-free.
private var showsReady: Bool { !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 }
@@ -347,6 +379,14 @@ struct ExerciseProgressView: View {
jumpToResumePage()
finishRestore()
}
} else if enteredViaFlow {
// Auto-advanced into this exercise (flow mode): no Ready tap begin the
// exercise, then let finishRestore broadcast its first work frame so a
// mirroring device follows across the exercise boundary (a discrete hand-off,
// not a shared-timer auto-advance it could reach on its own).
beginExercise()
announceCue()
finishRestore()
} else {
// Not-started opens on the Ready page.
finishRestore()
@@ -492,6 +532,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) {
@@ -500,7 +546,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)))
}
}
@@ -516,15 +562,40 @@ struct ExerciseProgressView: View {
} else {
let cycleIndex = index - base
if cycleIndex == cycleCount {
// Finish page confirm Done (auto-fires) or add One More.
VStack(spacing: 0) {
FinishPhaseView(
isActive: isActive,
onDone: { completeExercise(); dismiss() },
onOneMore: addSet,
anchorEnd: anchorEnd
)
adjustPill
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.
VStack(spacing: 0) {
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()
}
}
adjustPill
}
} else {
// Finish page confirm Done (auto-fires) or add One More.
VStack(spacing: 0) {
FinishPhaseView(
isActive: isActive,
onDone: { completeExercise(); dismiss() },
onOneMore: addSet,
anchorEnd: anchorEnd
)
adjustPill
}
}
} else if cycleIndex.isMultiple(of: 2) {
let setNumber = cycleIndex / 2 + 1
@@ -557,7 +628,7 @@ struct ExerciseProgressView: View {
CountdownPhaseView(
header: "Rest",
tint: .restTimer,
seconds: restSeconds,
seconds: effectiveRest,
isActive: isActive,
anchorStart: anchorStart,
anchorEnd: anchorEnd