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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// RunFlowView.swift
|
||||
// Workouts
|
||||
//
|
||||
// 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 — this only automates the "open the next exercise" step.
|
||||
///
|
||||
/// For a non-flow split nothing ever calls `advance`, so this is transparent: it presents
|
||||
/// one exercise and behaves identically to hosting `ExerciseProgressView` directly.
|
||||
struct RunFlowView: View {
|
||||
@Environment(LiveRunState.self) private var liveRun
|
||||
|
||||
@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 by log id
|
||||
/// because the run's current log advances; the leaving exercise's id is what ends.
|
||||
let sendLiveEnded: (String) -> Void
|
||||
let onActivity: (LiveProgress) -> Void
|
||||
let onActivityEnded: () -> Void
|
||||
var speechAnnouncer: SpeechAnnouncer? = nil
|
||||
|
||||
@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 / Live Activity 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,
|
||||
onActivity: @escaping (LiveProgress) -> Void, onActivityEnded: @escaping () -> Void,
|
||||
speechAnnouncer: SpeechAnnouncer? = nil) {
|
||||
self._doc = doc
|
||||
self.startLogID = startLogID
|
||||
self.onChange = onChange
|
||||
self.onLive = onLive
|
||||
self.sendLiveEnded = sendLiveEnded
|
||||
self.onActivity = onActivity
|
||||
self.onActivityEnded = onActivityEnded
|
||||
self.speechAnnouncer = speechAnnouncer
|
||||
_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) } },
|
||||
onActivity: onActivity,
|
||||
onActivityEnded: { if !advancing { onActivityEnded() } },
|
||||
incomingFrame: liveRun.current.flatMap { $0.logID == currentLogID ? $0 : nil },
|
||||
speechAnnouncer: speechAnnouncer,
|
||||
enteredViaFlow: enteredViaFlow,
|
||||
onAdvance: advance(to:)
|
||||
)
|
||||
.id(currentLogID)
|
||||
}
|
||||
.onAppear { liveRun.navigatedRunID = currentLogID }
|
||||
.onDisappear { if liveRun.navigatedRunID == currentLogID { liveRun.navigatedRunID = nil } }
|
||||
}
|
||||
|
||||
/// Hand off to the next exercise. Suppresses the leaving exercise's live-ended/activity
|
||||
/// 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
|
||||
liveRun.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 }
|
||||
}
|
||||
}
|
||||
@@ -240,19 +240,20 @@ struct WorkoutLogListView: View {
|
||||
/// (so the propped-phone mirror cover doesn't stack on top of it).
|
||||
@ViewBuilder
|
||||
private func progressView(logID: String) -> some View {
|
||||
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: { save() },
|
||||
onLive: { services.watchBridge.sendLiveProgress($0) },
|
||||
onLiveEnded: { services.watchBridge.sendLiveEnded(workoutID: doc.id, logID: logID) },
|
||||
sendLiveEnded: { lid in services.watchBridge.sendLiveEnded(workoutID: doc.id, logID: lid) },
|
||||
onActivity: { services.liveActivity.update($0) },
|
||||
onActivityEnded: { services.liveActivity.end() },
|
||||
incomingFrame: liveRun.current.flatMap { $0.logID == logID ? $0 : nil },
|
||||
speechAnnouncer: services.speechAnnouncer
|
||||
)
|
||||
.onAppear { liveRun.navigatedRunID = logID }
|
||||
.onDisappear { if liveRun.navigatedRunID == logID { liveRun.navigatedRunID = nil } }
|
||||
}
|
||||
|
||||
// MARK: - Derived
|
||||
|
||||
@@ -297,7 +297,8 @@ struct SplitPickerSheet: View {
|
||||
status: WorkoutStatus.notStarted.rawValue,
|
||||
createdAt: startDate,
|
||||
updatedAt: startDate,
|
||||
logs: logs
|
||||
logs: logs,
|
||||
restSeconds: split.restSeconds, autoAdvance: split.autoAdvance
|
||||
)
|
||||
|
||||
// Hand the id back after the save so the presenter can poll the cache and
|
||||
|
||||
Reference in New Issue
Block a user