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
+6
View File
@@ -1,5 +1,11 @@
**July 2026**
A new Auto-Advance option lets a split flow hands-free from one exercise to the next, resting automatically between them.
Each split can now set its own rest time, overriding the app-wide default in Settings.
The Morning Wake-Up starter split is now a timed routine that auto-advances through every move.
Tap the speaker on any library exercise to hear its full instructions read aloud.
Turn on Speak Exercise Cues in Settings to hear each exercise's setup and form cues spoken automatically when you start it, hands-free.
+8 -5
View File
@@ -7,11 +7,14 @@ your own iCloud Drive.
## Key Features
- **Workout splits** — organize exercises into reusable routines with custom
colors, icons, and an activity type (strength, HIIT, cardio, …). Built-in
starter splits (Upper Body / Core / Lower Body
machine routines, plus an equipment-free **Bodyweight Core** circuit with its
own warm-up) appear automatically on first launch; editing one makes it your
own copy, and deleted starters can be restored from Settings.
colors, icons, and an activity type (strength, HIIT, cardio, …). Each split can
set its own rest time (overriding the app-wide default) and turn on **Auto-Advance**
to flow hands-free from one exercise to the next, resting automatically between them
(the **Morning Wake-Up** starter split ships this way). Built-in starter splits
(Upper Body / Core / Lower Body machine routines, plus an equipment-free
**Bodyweight Core** circuit with its own warm-up) appear automatically on first
launch; editing one makes it your own copy, and deleted starters can be restored
from Settings.
- **Exercise library** — a bundled library of 47 exercises to populate your
splits, authored in `Exercise Library/` with per-exercise setup, cues,
mistakes, progressions, and an anatomically-rigged 3D stick-figure visual
+14 -11
View File
@@ -66,6 +66,8 @@ struct SplitDocument: Codable {
var updatedAt: Date
var exercises: [ExerciseDocument]
var activityType: Int?
var restSeconds: Int? = nil
var autoAdvance: Bool? = nil
}
// MARK: - Seed data (canonical source for Workouts/Resources/StarterSplits/*.json)
@@ -74,7 +76,7 @@ struct SplitDocument: Codable {
// (empty = nothing recorded yet), which lights up the machine-settings UI without
// the user having to flip the editor toggle first.
struct Ex { let name: String; var weight: Double = 0; var sets = 4; var reps = 10; var load = 1; var dur = 0; var machine = false }
struct Sp { let name: String; let color: String; let icon: String; let activity: Int; let ex: [Ex] }
struct Sp { let name: String; let color: String; let icon: String; let activity: Int; var restSeconds: Int? = nil; var autoAdvance: Bool? = nil; let ex: [Ex] }
let seeds: [Sp] = [
Sp(name: "Upper Body", color: "blue", icon: "figure.strengthtraining.traditional", activity: 0, ex: [
@@ -139,16 +141,16 @@ let seeds: [Sp] = [
Ex(name: "Glute Bridge", sets: 2, reps: 10, load: 0),
Ex(name: "Standing Calf Raise", sets: 2, reps: 12, load: 0),
]),
Sp(name: "Morning Wake-Up", color: "yellow", icon: "sun.max.fill", activity: 1, ex: [
Sp(name: "Morning Wake-Up", color: "yellow", icon: "sun.max.fill", activity: 1, restSeconds: 15, autoAdvance: true, ex: [
Ex(name: "March in Place", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Neck Rolls", sets: 1, reps: 5, load: 0),
Ex(name: "Arm Circles", sets: 1, reps: 10, load: 0),
Ex(name: "Torso Twist", sets: 1, reps: 10, load: 0),
Ex(name: "Standing Side Bend", sets: 1, reps: 8, load: 0),
Ex(name: "Hip Circles", sets: 1, reps: 8, load: 0),
Ex(name: "Leg Swings", sets: 1, reps: 10, load: 0),
Ex(name: "Standing Calf Raise", sets: 1, reps: 12, load: 0),
Ex(name: "Standing Forward Fold", sets: 1, reps: 0, load: 2, dur: 30),
Ex(name: "Neck Rolls", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Arm Circles", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Torso Twist", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Standing Side Bend", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Hip Circles", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Leg Swings", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Standing Calf Raise", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Standing Forward Fold", sets: 1, reps: 0, load: 2, dur: 45),
]),
Sp(name: "Morning Mobility", color: "pink", icon: "figure.yoga", activity: 1, ex: [
Ex(name: "Cat-Cow", sets: 1, reps: 10, load: 0),
@@ -201,7 +203,8 @@ for (order, sp) in seeds.enumerated() {
loadType: e.load, durationSeconds: e.dur,
machineSettings: e.machine ? [] : nil)
},
activityType: sp.activity)
activityType: sp.activity,
restSeconds: sp.restSeconds, autoAdvance: sp.autoAdvance)
let data = try encoder.encode(doc)
let file = outDir.appendingPathComponent("\(sp.name).split.json")
try data.write(to: file)
+13
View File
@@ -29,6 +29,12 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
/// older app dropping it on rewrite (reverting to the default) is preferable to
/// quarantining the user's whole routine.
var activityType: Int?
/// Per-split rest length (seconds) and flow-mode toggle. Both optional and
/// deliberately NOT schema-bumped, same rationale as `activityType`: an older
/// app dropping them on rewrite reverts to the global default rest / no-flow,
/// which is preferable to quarantining the user's whole routine.
var restSeconds: Int? = nil
var autoAdvance: Bool? = nil
// Bumped 12 when the weight-reminder fields (`weightLastUpdated`,
// `weightReminderWeeks`) and `category` were removed and `machineSettings` was
@@ -103,6 +109,13 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
/// period. See `WorkoutMergePlanner`.
var deletedLogIDs: [String: Date]? = nil
/// Snapshot of the split's rest length / flow flag at plan time (a running
/// workout has no live link to its split, same reason sets/reps/weight are
/// snapshotted per log). Optional and not schema-bumped, same rationale as
/// `SplitDocument.restSeconds`/`autoAdvance`.
var restSeconds: Int? = nil
var autoAdvance: Bool? = nil
// Bumped 12 when `metrics` was added: the captured HR/calorie data is
// irreplaceable, so the forward-gate must quarantine these files on older apps
// rather than let them strip `metrics` on rewrite.
+4
View File
@@ -24,6 +24,8 @@ final class Split {
var updatedAt: Date = Date()
var jsonRelativePath: String = ""
var activityTypeRaw: Int = 0
var restSeconds: Int?
var autoAdvance: Bool?
@Relationship(deleteRule: .cascade, inverse: \Exercise.split)
var exercises: [Exercise] = []
@@ -148,6 +150,8 @@ final class Workout {
/// phone re-derives them into any `WorkoutDocument` it rebuilds and the merge stays
/// deletion-safe across cache round-trips. Phone-authored; see `WorkoutMergePlanner`.
var deletedLogIDs: [String: Date]?
var restSeconds: Int?
var autoAdvance: Bool?
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
var logs: [WorkoutLog] = []
+8 -2
View File
@@ -27,7 +27,8 @@ extension SplitDocument {
color: split.color, systemImage: split.systemImage, order: split.order,
createdAt: split.createdAt, updatedAt: split.updatedAt,
exercises: split.exercisesArray.map(ExerciseDocument.init(from:)),
activityType: split.activityTypeRaw)
activityType: split.activityTypeRaw,
restSeconds: split.restSeconds, autoAdvance: split.autoAdvance)
}
}
@@ -50,7 +51,8 @@ extension WorkoutDocument {
status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt,
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)),
metrics: workout.metrics,
deletedLogIDs: workout.deletedLogIDs)
deletedLogIDs: workout.deletedLogIDs,
restSeconds: workout.restSeconds, autoAdvance: workout.autoAdvance)
}
}
@@ -89,6 +91,8 @@ enum CacheMapper {
split.updatedAt = doc.updatedAt
split.jsonRelativePath = relativePath
split.activityTypeRaw = doc.activityType ?? 0
split.restSeconds = doc.restSeconds
split.autoAdvance = doc.autoAdvance
let existing = Dictionary(split.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
var keep = Set<String>()
@@ -147,6 +151,8 @@ enum CacheMapper {
workout.jsonRelativePath = relativePath
workout.metrics = doc.metrics
workout.deletedLogIDs = doc.deletedLogIDs
workout.restSeconds = doc.restSeconds
workout.autoAdvance = doc.autoAdvance
let existing = Dictionary(workout.logs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
var keep = Set<String>()
+4
View File
@@ -18,7 +18,11 @@ struct ContentView: View {
set: { presenting in if !presenting { bridge.muteLive() } }
)) {
if let frame = bridge.presentable {
// Re-key on the followed log so a flow-mode auto-advance (the driver
// moving to the next exercise) rebuilds the mirror for the new exercise
// instead of leaving a stale one pinned to the previous log.
LiveRunCoverView(frame: frame) { bridge.muteLive() }
.id(frame.logID)
}
}
}
@@ -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
+4
View File
@@ -19,7 +19,11 @@ struct ContentView: View {
set: { presenting in if !presenting { liveRun.mute() } }
)) {
if let frame = liveRun.presentable {
// Re-key on the followed log so a flow-mode auto-advance (the driver
// moving to the next exercise) rebuilds the mirror for the new exercise
// instead of leaving a stale one pinned to the previous log.
LiveRunCoverView(frame: frame) { liveRun.mute() }
.id(frame.logID)
}
}
}
@@ -1,5 +1,6 @@
{
"activityType" : 1,
"autoAdvance" : true,
"color" : "yellow",
"createdAt" : "2020-01-01T00:00:00Z",
"exercises" : [
@@ -14,77 +15,77 @@
"weight" : 0
},
{
"durationSeconds" : 0,
"durationSeconds" : 45,
"id" : "01DXF6DT0090FANSSPTBBYFG08",
"loadType" : 0,
"loadType" : 2,
"name" : "Neck Rolls",
"order" : 1,
"reps" : 5,
"reps" : 0,
"sets" : 1,
"weight" : 0
},
{
"durationSeconds" : 0,
"durationSeconds" : 45,
"id" : "01DXF6DT00GKZKMRKHQ7AARGWG",
"loadType" : 0,
"loadType" : 2,
"name" : "Arm Circles",
"order" : 2,
"reps" : 10,
"reps" : 0,
"sets" : 1,
"weight" : 0
},
{
"durationSeconds" : 0,
"durationSeconds" : 45,
"id" : "01DXF6DT00KG2640BBEXTTFNM3",
"loadType" : 0,
"loadType" : 2,
"name" : "Torso Twist",
"order" : 3,
"reps" : 10,
"reps" : 0,
"sets" : 1,
"weight" : 0
},
{
"durationSeconds" : 0,
"durationSeconds" : 45,
"id" : "01DXF6DT005JW5BTSHWY54S6P3",
"loadType" : 0,
"loadType" : 2,
"name" : "Standing Side Bend",
"order" : 4,
"reps" : 8,
"reps" : 0,
"sets" : 1,
"weight" : 0
},
{
"durationSeconds" : 0,
"durationSeconds" : 45,
"id" : "01DXF6DT004CYSBPP0KYDMJMMG",
"loadType" : 0,
"loadType" : 2,
"name" : "Hip Circles",
"order" : 5,
"reps" : 8,
"reps" : 0,
"sets" : 1,
"weight" : 0
},
{
"durationSeconds" : 0,
"durationSeconds" : 45,
"id" : "01DXF6DT00TSP140VH9JYVHS15",
"loadType" : 0,
"loadType" : 2,
"name" : "Leg Swings",
"order" : 6,
"reps" : 10,
"reps" : 0,
"sets" : 1,
"weight" : 0
},
{
"durationSeconds" : 0,
"durationSeconds" : 45,
"id" : "01DXF6DT00J0C4EADB3VRKEJB2",
"loadType" : 0,
"loadType" : 2,
"name" : "Standing Calf Raise",
"order" : 7,
"reps" : 12,
"reps" : 0,
"sets" : 1,
"weight" : 0
},
{
"durationSeconds" : 30,
"durationSeconds" : 45,
"id" : "01DXF6DT00BMMGNKB4B6TQ5E59",
"loadType" : 2,
"name" : "Standing Forward Fold",
@@ -97,6 +98,7 @@
"id" : "01DXF6DT00FJ5F1HRENQPC1DTG",
"name" : "Morning Wake-Up",
"order" : 8,
"restSeconds" : 15,
"schemaVersion" : 3,
"systemImage" : "sun.max.fill",
"updatedAt" : "2020-01-01T00:00:00Z"
@@ -214,7 +214,8 @@ struct ExerciseListView: View {
status: WorkoutStatus.notStarted.rawValue,
createdAt: startDate,
updatedAt: startDate,
logs: logs
logs: logs,
restSeconds: split.restSeconds, autoAdvance: split.autoAdvance
)
Task {
await sync.save(workout: doc)
+30 -1
View File
@@ -29,6 +29,9 @@ struct SplitAddEditView: View {
@State private var color: String = "indigo"
@State private var systemImage: String = "dumbbell.fill"
@State private var activityType: WorkoutActivityType = .traditionalStrength
@State private var autoAdvance: Bool = false
@State private var restOverrideEnabled: Bool = false
@State private var restSecondsValue: Int = 45
@State private var showingIconPicker: Bool = false
@State private var showingDeleteConfirmation: Bool = false
@@ -42,6 +45,9 @@ struct SplitAddEditView: View {
_color = State(initialValue: split.color)
_systemImage = State(initialValue: split.systemImage)
_activityType = State(initialValue: split.activityTypeEnum)
_autoAdvance = State(initialValue: split.autoAdvance ?? false)
_restOverrideEnabled = State(initialValue: split.restSeconds != nil)
_restSecondsValue = State(initialValue: split.restSeconds ?? 45)
}
}
@@ -111,6 +117,25 @@ struct SplitAddEditView: View {
Text("Determines how this workout is categorized in Apple Health and how it credits your Activity rings.")
}
Section {
Toggle("Auto-Advance Exercises", isOn: $autoAdvance)
Toggle("Custom Rest Time", isOn: $restOverrideEnabled)
if restOverrideEnabled {
Stepper(value: $restSecondsValue, in: 10...180, step: 5) {
HStack {
Text("Rest Time")
Spacer()
Text("\(restSecondsValue)s").foregroundColor(.secondary)
}
}
}
} header: {
Text("Rest & Pacing")
} footer: {
Text("Auto-Advance flows from one exercise to the next with a rest between, so you can run the whole split hands-free. Custom Rest Time sets this split's rest — used between sets, and between exercises when auto-advancing; otherwise the Settings default applies.")
}
if let split = split {
Section(header: Text("Exercises")) {
NavigationLink {
@@ -178,6 +203,8 @@ struct SplitAddEditView: View {
doc.color = color
doc.systemImage = systemImage
doc.activityType = activityType.rawValue
doc.restSeconds = restOverrideEnabled ? restSecondsValue : nil
doc.autoAdvance = autoAdvance ? true : nil
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
} else {
@@ -193,7 +220,9 @@ struct SplitAddEditView: View {
createdAt: Date(),
updatedAt: Date(),
exercises: [],
activityType: activityType.rawValue
activityType: activityType.rawValue,
restSeconds: restOverrideEnabled ? restSecondsValue : nil,
autoAdvance: autoAdvance ? true : nil
)
Task { await sync.save(split: doc) }
}
@@ -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