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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user