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** **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. 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. 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 ## Key Features
- **Workout splits** — organize exercises into reusable routines with custom - **Workout splits** — organize exercises into reusable routines with custom
colors, icons, and an activity type (strength, HIIT, cardio, …). Built-in colors, icons, and an activity type (strength, HIIT, cardio, …). Each split can
starter splits (Upper Body / Core / Lower Body set its own rest time (overriding the app-wide default) and turn on **Auto-Advance**
machine routines, plus an equipment-free **Bodyweight Core** circuit with its to flow hands-free from one exercise to the next, resting automatically between them
own warm-up) appear automatically on first launch; editing one makes it your (the **Morning Wake-Up** starter split ships this way). Built-in starter splits
own copy, and deleted starters can be restored from Settings. (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 - **Exercise library** — a bundled library of 47 exercises to populate your
splits, authored in `Exercise Library/` with per-exercise setup, cues, splits, authored in `Exercise Library/` with per-exercise setup, cues,
mistakes, progressions, and an anatomically-rigged 3D stick-figure visual 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 updatedAt: Date
var exercises: [ExerciseDocument] var exercises: [ExerciseDocument]
var activityType: Int? var activityType: Int?
var restSeconds: Int? = nil
var autoAdvance: Bool? = nil
} }
// MARK: - Seed data (canonical source for Workouts/Resources/StarterSplits/*.json) // 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 // (empty = nothing recorded yet), which lights up the machine-settings UI without
// the user having to flip the editor toggle first. // 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 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] = [ let seeds: [Sp] = [
Sp(name: "Upper Body", color: "blue", icon: "figure.strengthtraining.traditional", activity: 0, ex: [ 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: "Glute Bridge", sets: 2, reps: 10, load: 0),
Ex(name: "Standing Calf Raise", sets: 2, reps: 12, 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: "March in Place", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Neck Rolls", sets: 1, reps: 5, load: 0), Ex(name: "Neck Rolls", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Arm Circles", sets: 1, reps: 10, load: 0), Ex(name: "Arm Circles", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Torso Twist", sets: 1, reps: 10, load: 0), Ex(name: "Torso Twist", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Standing Side Bend", sets: 1, reps: 8, load: 0), Ex(name: "Standing Side Bend", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Hip Circles", sets: 1, reps: 8, load: 0), Ex(name: "Hip Circles", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Leg Swings", sets: 1, reps: 10, load: 0), Ex(name: "Leg Swings", sets: 1, reps: 0, load: 2, dur: 45),
Ex(name: "Standing Calf Raise", sets: 1, reps: 12, load: 0), 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: 30), 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: [ Sp(name: "Morning Mobility", color: "pink", icon: "figure.yoga", activity: 1, ex: [
Ex(name: "Cat-Cow", sets: 1, reps: 10, load: 0), 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, loadType: e.load, durationSeconds: e.dur,
machineSettings: e.machine ? [] : nil) machineSettings: e.machine ? [] : nil)
}, },
activityType: sp.activity) activityType: sp.activity,
restSeconds: sp.restSeconds, autoAdvance: sp.autoAdvance)
let data = try encoder.encode(doc) let data = try encoder.encode(doc)
let file = outDir.appendingPathComponent("\(sp.name).split.json") let file = outDir.appendingPathComponent("\(sp.name).split.json")
try data.write(to: file) 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 /// older app dropping it on rewrite (reverting to the default) is preferable to
/// quarantining the user's whole routine. /// quarantining the user's whole routine.
var activityType: Int? 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`, // Bumped 12 when the weight-reminder fields (`weightLastUpdated`,
// `weightReminderWeeks`) and `category` were removed and `machineSettings` was // `weightReminderWeeks`) and `category` were removed and `machineSettings` was
@@ -103,6 +109,13 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
/// period. See `WorkoutMergePlanner`. /// period. See `WorkoutMergePlanner`.
var deletedLogIDs: [String: Date]? = nil 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 // Bumped 12 when `metrics` was added: the captured HR/calorie data is
// irreplaceable, so the forward-gate must quarantine these files on older apps // irreplaceable, so the forward-gate must quarantine these files on older apps
// rather than let them strip `metrics` on rewrite. // rather than let them strip `metrics` on rewrite.
+4
View File
@@ -24,6 +24,8 @@ final class Split {
var updatedAt: Date = Date() var updatedAt: Date = Date()
var jsonRelativePath: String = "" var jsonRelativePath: String = ""
var activityTypeRaw: Int = 0 var activityTypeRaw: Int = 0
var restSeconds: Int?
var autoAdvance: Bool?
@Relationship(deleteRule: .cascade, inverse: \Exercise.split) @Relationship(deleteRule: .cascade, inverse: \Exercise.split)
var exercises: [Exercise] = [] var exercises: [Exercise] = []
@@ -148,6 +150,8 @@ final class Workout {
/// phone re-derives them into any `WorkoutDocument` it rebuilds and the merge stays /// phone re-derives them into any `WorkoutDocument` it rebuilds and the merge stays
/// deletion-safe across cache round-trips. Phone-authored; see `WorkoutMergePlanner`. /// deletion-safe across cache round-trips. Phone-authored; see `WorkoutMergePlanner`.
var deletedLogIDs: [String: Date]? var deletedLogIDs: [String: Date]?
var restSeconds: Int?
var autoAdvance: Bool?
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout) @Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
var logs: [WorkoutLog] = [] var logs: [WorkoutLog] = []
+8 -2
View File
@@ -27,7 +27,8 @@ extension SplitDocument {
color: split.color, systemImage: split.systemImage, order: split.order, color: split.color, systemImage: split.systemImage, order: split.order,
createdAt: split.createdAt, updatedAt: split.updatedAt, createdAt: split.createdAt, updatedAt: split.updatedAt,
exercises: split.exercisesArray.map(ExerciseDocument.init(from:)), 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, status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt,
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)), logs: workout.logsArray.map(WorkoutLogDocument.init(from:)),
metrics: workout.metrics, 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.updatedAt = doc.updatedAt
split.jsonRelativePath = relativePath split.jsonRelativePath = relativePath
split.activityTypeRaw = doc.activityType ?? 0 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 }) let existing = Dictionary(split.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
var keep = Set<String>() var keep = Set<String>()
@@ -147,6 +151,8 @@ enum CacheMapper {
workout.jsonRelativePath = relativePath workout.jsonRelativePath = relativePath
workout.metrics = doc.metrics workout.metrics = doc.metrics
workout.deletedLogIDs = doc.deletedLogIDs 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 }) let existing = Dictionary(workout.logs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
var keep = Set<String>() var keep = Set<String>()
+4
View File
@@ -18,7 +18,11 @@ struct ContentView: View {
set: { presenting in if !presenting { bridge.muteLive() } } set: { presenting in if !presenting { bridge.muteLive() } }
)) { )) {
if let frame = bridge.presentable { 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() } LiveRunCoverView(frame: frame) { bridge.muteLive() }
.id(frame.logID)
} }
} }
} }
@@ -40,6 +40,14 @@ struct ExerciseProgressView: View {
let onLive: (LiveProgress) -> Void let onLive: (LiveProgress) -> Void
let onLiveEnded: () -> 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 /// 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 /// 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. /// 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. /// also suppresses the Ready page so the index is a plain work/rest cycle offset.
private let debugInitialPage: Int? 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._doc = doc
self.logID = logID self.logID = logID
self.onChange = onChange self.onChange = onChange
self.onLive = onLive self.onLive = onLive
self.onLiveEnded = onLiveEnded self.onLiveEnded = onLiveEnded
self.enteredViaFlow = enteredViaFlow
self.onAdvance = onAdvance
self.incomingFrame = incomingFrame self.incomingFrame = incomingFrame
self.debugInitialPage = debugInitialPage self.debugInitialPage = debugInitialPage
@@ -116,10 +126,11 @@ struct ExerciseProgressView: View {
_knownStateIndex = State(initialValue: max(0, log?.currentStateIndex ?? 0)) _knownStateIndex = State(initialValue: max(0, log?.currentStateIndex ?? 0))
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
// The Ready page always leads the flow (except in the screenshot host). A // The Ready page always leads the flow (except in the screenshot host, or a
// not-started run opens on it; an in-progress run opens on its first unfinished // flow-advanced entry which begins immediately). A not-started run opens on it; an
// set and re-asserts that page past the TabView's snap-to-0 on first layout. // in-progress run opens on its first unfinished set and re-asserts that page past
let ready = debugInitialPage == nil // the TabView's snap-to-0 on first layout.
let ready = debugInitialPage == nil && !enteredViaFlow
_startsResumed = State(initialValue: ready && !notStarted) _startsResumed = State(initialValue: ready && !notStarted)
_startsCompleted = State(initialValue: ready && log?.status == WorkoutStatus.completed.rawValue) _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 /// 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 /// (which resets the exercise). Suppressed for the screenshot host (which pins an
/// explicit page. Derived from the immutable `debugInitialPage`, so it stays stable for /// explicit page) and for a flow-advanced entry (which begins immediately). Derived
/// the life of the screen the page-index math below depends on it. /// from immutable inputs, so it stays stable for the life of the screen the
private var showsReady: Bool { debugInitialPage == nil } /// 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`. /// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
private var base: Int { showsReady ? 1 : 0 } private var base: Int { showsReady ? 1 : 0 }
@@ -292,6 +323,12 @@ struct ExerciseProgressView: View {
jumpToResumePage() jumpToResumePage()
finishRestore() 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 { } else {
// Not-started opens on the Ready page; the screenshot host pins its own. // Not-started opens on the Ready page; the screenshot host pins its own.
finishRestore() finishRestore()
@@ -409,6 +446,12 @@ struct ExerciseProgressView: View {
} }
let cycleIndex = page - base let cycleIndex = page - base
if cycleIndex == cycleCount { 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), return frame(.finish, setIndex: max(0, setCount - 1),
end: now.addingTimeInterval(Double(max(1, doneCountdownSeconds)))) end: now.addingTimeInterval(Double(max(1, doneCountdownSeconds))))
} else if cycleIndex.isMultiple(of: 2) { } else if cycleIndex.isMultiple(of: 2) {
@@ -417,7 +460,7 @@ struct ExerciseProgressView: View {
return frame(.work, setIndex: set, end: end) return frame(.work, setIndex: set, end: end)
} else { } else {
let set = (cycleIndex - 1) / 2 // the rest follows this set 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 { } else {
let cycleIndex = index - base let cycleIndex = index - base
if cycleIndex == cycleCount { if cycleIndex == cycleCount {
// Finish page confirm Done (auto-fires) or add One More. if autoAdvance, nextUnfinishedLogID != nil {
FinishPhaseView( // Flow mode: the terminal page is a between-exercise rest that
isActive: isActive, // completes this exercise and hands off to the next when it ends.
onDone: { completeExercise(); dismiss() }, CountdownPhaseView(
onOneMore: addSet, header: "Rest",
anchorEnd: anchorEnd 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) { } else if cycleIndex.isMultiple(of: 2) {
let setNumber = cycleIndex / 2 + 1 let setNumber = cycleIndex / 2 + 1
if isDuration { if isDuration {
@@ -480,7 +545,7 @@ struct ExerciseProgressView: View {
CountdownPhaseView( CountdownPhaseView(
header: "Rest", header: "Rest",
tint: .restTimer, tint: .restTimer,
seconds: restSeconds, seconds: effectiveRest,
isActive: isActive, isActive: isActive,
anchorStart: anchorStart, anchorStart: anchorStart,
anchorEnd: anchorEnd 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 .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, doc: $doc,
logID: logID, startLogID: logID,
onChange: { bridge.update(workout: doc) }, onChange: { bridge.update(workout: doc) },
onLive: { bridge.sendLiveProgress($0) }, onLive: { bridge.sendLiveProgress($0) },
onLiveEnded: { bridge.sendLiveEnded(workoutID: doc.id, logID: logID) }, sendLiveEnded: { lid in bridge.sendLiveEnded(workoutID: doc.id, logID: lid) }
// Follow the phone when it drives this same run from its mirror.
incomingFrame: bridge.liveIncoming.flatMap { $0.logID == logID ? $0 : nil }
) )
// 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) { .sheet(isPresented: $showingExercisePicker) {
ExercisePickerView(exercises: availableExercises) { exercise in ExercisePickerView(exercises: availableExercises) { exercise in
+4
View File
@@ -19,7 +19,11 @@ struct ContentView: View {
set: { presenting in if !presenting { liveRun.mute() } } set: { presenting in if !presenting { liveRun.mute() } }
)) { )) {
if let frame = liveRun.presentable { 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() } LiveRunCoverView(frame: frame) { liveRun.mute() }
.id(frame.logID)
} }
} }
} }
@@ -1,5 +1,6 @@
{ {
"activityType" : 1, "activityType" : 1,
"autoAdvance" : true,
"color" : "yellow", "color" : "yellow",
"createdAt" : "2020-01-01T00:00:00Z", "createdAt" : "2020-01-01T00:00:00Z",
"exercises" : [ "exercises" : [
@@ -14,77 +15,77 @@
"weight" : 0 "weight" : 0
}, },
{ {
"durationSeconds" : 0, "durationSeconds" : 45,
"id" : "01DXF6DT0090FANSSPTBBYFG08", "id" : "01DXF6DT0090FANSSPTBBYFG08",
"loadType" : 0, "loadType" : 2,
"name" : "Neck Rolls", "name" : "Neck Rolls",
"order" : 1, "order" : 1,
"reps" : 5, "reps" : 0,
"sets" : 1, "sets" : 1,
"weight" : 0 "weight" : 0
}, },
{ {
"durationSeconds" : 0, "durationSeconds" : 45,
"id" : "01DXF6DT00GKZKMRKHQ7AARGWG", "id" : "01DXF6DT00GKZKMRKHQ7AARGWG",
"loadType" : 0, "loadType" : 2,
"name" : "Arm Circles", "name" : "Arm Circles",
"order" : 2, "order" : 2,
"reps" : 10, "reps" : 0,
"sets" : 1, "sets" : 1,
"weight" : 0 "weight" : 0
}, },
{ {
"durationSeconds" : 0, "durationSeconds" : 45,
"id" : "01DXF6DT00KG2640BBEXTTFNM3", "id" : "01DXF6DT00KG2640BBEXTTFNM3",
"loadType" : 0, "loadType" : 2,
"name" : "Torso Twist", "name" : "Torso Twist",
"order" : 3, "order" : 3,
"reps" : 10, "reps" : 0,
"sets" : 1, "sets" : 1,
"weight" : 0 "weight" : 0
}, },
{ {
"durationSeconds" : 0, "durationSeconds" : 45,
"id" : "01DXF6DT005JW5BTSHWY54S6P3", "id" : "01DXF6DT005JW5BTSHWY54S6P3",
"loadType" : 0, "loadType" : 2,
"name" : "Standing Side Bend", "name" : "Standing Side Bend",
"order" : 4, "order" : 4,
"reps" : 8, "reps" : 0,
"sets" : 1, "sets" : 1,
"weight" : 0 "weight" : 0
}, },
{ {
"durationSeconds" : 0, "durationSeconds" : 45,
"id" : "01DXF6DT004CYSBPP0KYDMJMMG", "id" : "01DXF6DT004CYSBPP0KYDMJMMG",
"loadType" : 0, "loadType" : 2,
"name" : "Hip Circles", "name" : "Hip Circles",
"order" : 5, "order" : 5,
"reps" : 8, "reps" : 0,
"sets" : 1, "sets" : 1,
"weight" : 0 "weight" : 0
}, },
{ {
"durationSeconds" : 0, "durationSeconds" : 45,
"id" : "01DXF6DT00TSP140VH9JYVHS15", "id" : "01DXF6DT00TSP140VH9JYVHS15",
"loadType" : 0, "loadType" : 2,
"name" : "Leg Swings", "name" : "Leg Swings",
"order" : 6, "order" : 6,
"reps" : 10, "reps" : 0,
"sets" : 1, "sets" : 1,
"weight" : 0 "weight" : 0
}, },
{ {
"durationSeconds" : 0, "durationSeconds" : 45,
"id" : "01DXF6DT00J0C4EADB3VRKEJB2", "id" : "01DXF6DT00J0C4EADB3VRKEJB2",
"loadType" : 0, "loadType" : 2,
"name" : "Standing Calf Raise", "name" : "Standing Calf Raise",
"order" : 7, "order" : 7,
"reps" : 12, "reps" : 0,
"sets" : 1, "sets" : 1,
"weight" : 0 "weight" : 0
}, },
{ {
"durationSeconds" : 30, "durationSeconds" : 45,
"id" : "01DXF6DT00BMMGNKB4B6TQ5E59", "id" : "01DXF6DT00BMMGNKB4B6TQ5E59",
"loadType" : 2, "loadType" : 2,
"name" : "Standing Forward Fold", "name" : "Standing Forward Fold",
@@ -97,6 +98,7 @@
"id" : "01DXF6DT00FJ5F1HRENQPC1DTG", "id" : "01DXF6DT00FJ5F1HRENQPC1DTG",
"name" : "Morning Wake-Up", "name" : "Morning Wake-Up",
"order" : 8, "order" : 8,
"restSeconds" : 15,
"schemaVersion" : 3, "schemaVersion" : 3,
"systemImage" : "sun.max.fill", "systemImage" : "sun.max.fill",
"updatedAt" : "2020-01-01T00:00:00Z" "updatedAt" : "2020-01-01T00:00:00Z"
@@ -214,7 +214,8 @@ struct ExerciseListView: View {
status: WorkoutStatus.notStarted.rawValue, status: WorkoutStatus.notStarted.rawValue,
createdAt: startDate, createdAt: startDate,
updatedAt: startDate, updatedAt: startDate,
logs: logs logs: logs,
restSeconds: split.restSeconds, autoAdvance: split.autoAdvance
) )
Task { Task {
await sync.save(workout: doc) 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 color: String = "indigo"
@State private var systemImage: String = "dumbbell.fill" @State private var systemImage: String = "dumbbell.fill"
@State private var activityType: WorkoutActivityType = .traditionalStrength @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 showingIconPicker: Bool = false
@State private var showingDeleteConfirmation: Bool = false @State private var showingDeleteConfirmation: Bool = false
@@ -42,6 +45,9 @@ struct SplitAddEditView: View {
_color = State(initialValue: split.color) _color = State(initialValue: split.color)
_systemImage = State(initialValue: split.systemImage) _systemImage = State(initialValue: split.systemImage)
_activityType = State(initialValue: split.activityTypeEnum) _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.") 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 { if let split = split {
Section(header: Text("Exercises")) { Section(header: Text("Exercises")) {
NavigationLink { NavigationLink {
@@ -178,6 +203,8 @@ struct SplitAddEditView: View {
doc.color = color doc.color = color
doc.systemImage = systemImage doc.systemImage = systemImage
doc.activityType = activityType.rawValue doc.activityType = activityType.rawValue
doc.restSeconds = restOverrideEnabled ? restSecondsValue : nil
doc.autoAdvance = autoAdvance ? true : nil
doc.updatedAt = Date() doc.updatedAt = Date()
Task { await sync.save(split: doc) } Task { await sync.save(split: doc) }
} else { } else {
@@ -193,7 +220,9 @@ struct SplitAddEditView: View {
createdAt: Date(), createdAt: Date(),
updatedAt: Date(), updatedAt: Date(),
exercises: [], exercises: [],
activityType: activityType.rawValue activityType: activityType.rawValue,
restSeconds: restOverrideEnabled ? restSecondsValue : nil,
autoAdvance: autoAdvance ? true : nil
) )
Task { await sync.save(split: doc) } Task { await sync.save(split: doc) }
} }
@@ -38,6 +38,16 @@ struct ExerciseProgressView: View {
let logID: String let logID: String
let onChange: () -> Void 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 /// 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. /// 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, /// Only *human* transitions are broadcast an auto-advance (rest/timed-work end) isn't,
@@ -135,7 +145,7 @@ struct ExerciseProgressView: View {
/// `startsCompleted`. /// `startsCompleted`.
@State private var startsSkipped: Bool @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._doc = doc
self.logID = logID self.logID = logID
self.onChange = onChange self.onChange = onChange
@@ -145,6 +155,8 @@ struct ExerciseProgressView: View {
self.onActivityEnded = onActivityEnded self.onActivityEnded = onActivityEnded
self.incomingFrame = incomingFrame self.incomingFrame = incomingFrame
self.speechAnnouncer = speechAnnouncer self.speechAnnouncer = speechAnnouncer
self.enteredViaFlow = enteredViaFlow
self.onAdvance = onAdvance
let log = doc.wrappedValue.logs.first { $0.id == logID } let log = doc.wrappedValue.logs.first { $0.id == logID }
let sets = max(1, log?.sets ?? 1) let sets = max(1, log?.sets ?? 1)
@@ -159,7 +171,8 @@ struct ExerciseProgressView: View {
_startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue) _startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue)
_startsSkipped = State(initialValue: log?.status == WorkoutStatus.skipped.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). // 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 completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1)
let resume = base + completed * 2 let resume = base + completed * 2
@@ -170,10 +183,29 @@ struct ExerciseProgressView: View {
doc.logs.first { $0.id == logID } doc.logs.first { $0.id == logID }
} }
/// The **Ready?** page always leads the flow, so a resumed run can swipe back to it /// The **Ready?** page leads the flow so a resumed run can swipe back to it (which
/// (which resets the exercise). (The watch additionally suppresses it for its /// resets the exercise). A flow-advanced entry skips it the exercise begins on
/// screenshot host; the iPhone has no such host, so it's always shown.) /// appear, hands-free.
private var showsReady: Bool { true } 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`. /// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
private var base: Int { showsReady ? 1 : 0 } private var base: Int { showsReady ? 1 : 0 }
@@ -347,6 +379,14 @@ struct ExerciseProgressView: View {
jumpToResumePage() jumpToResumePage()
finishRestore() 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 { } else {
// Not-started opens on the Ready page. // Not-started opens on the Ready page.
finishRestore() finishRestore()
@@ -492,6 +532,12 @@ struct ExerciseProgressView: View {
} }
let cycleIndex = page - base let cycleIndex = page - base
if cycleIndex == cycleCount { 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), return frame(.finish, setIndex: max(0, setCount - 1),
end: now.addingTimeInterval(Double(max(1, doneCountdownSeconds)))) end: now.addingTimeInterval(Double(max(1, doneCountdownSeconds))))
} else if cycleIndex.isMultiple(of: 2) { } else if cycleIndex.isMultiple(of: 2) {
@@ -500,7 +546,7 @@ struct ExerciseProgressView: View {
return frame(.work, setIndex: set, end: end) return frame(.work, setIndex: set, end: end)
} else { } else {
let set = (cycleIndex - 1) / 2 // the rest follows this set 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 { } else {
let cycleIndex = index - base let cycleIndex = index - base
if cycleIndex == cycleCount { if cycleIndex == cycleCount {
// Finish page confirm Done (auto-fires) or add One More. if autoAdvance, nextUnfinishedLogID != nil {
VStack(spacing: 0) { // Flow mode: the terminal page is a between-exercise rest that
FinishPhaseView( // completes this exercise and hands off to the next when it ends.
isActive: isActive, VStack(spacing: 0) {
onDone: { completeExercise(); dismiss() }, CountdownPhaseView(
onOneMore: addSet, header: "Rest",
anchorEnd: anchorEnd tint: .restTimer,
) seconds: effectiveRest,
adjustPill 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) { } else if cycleIndex.isMultiple(of: 2) {
let setNumber = cycleIndex / 2 + 1 let setNumber = cycleIndex / 2 + 1
@@ -557,7 +628,7 @@ struct ExerciseProgressView: View {
CountdownPhaseView( CountdownPhaseView(
header: "Rest", header: "Rest",
tint: .restTimer, tint: .restTimer,
seconds: restSeconds, seconds: effectiveRest,
isActive: isActive, isActive: isActive,
anchorStart: anchorStart, anchorStart: anchorStart,
anchorEnd: anchorEnd 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). /// (so the propped-phone mirror cover doesn't stack on top of it).
@ViewBuilder @ViewBuilder
private func progressView(logID: String) -> some View { 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, doc: $doc,
logID: logID, startLogID: logID,
onChange: { save() }, onChange: { save() },
onLive: { services.watchBridge.sendLiveProgress($0) }, 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) }, onActivity: { services.liveActivity.update($0) },
onActivityEnded: { services.liveActivity.end() }, onActivityEnded: { services.liveActivity.end() },
incomingFrame: liveRun.current.flatMap { $0.logID == logID ? $0 : nil },
speechAnnouncer: services.speechAnnouncer speechAnnouncer: services.speechAnnouncer
) )
.onAppear { liveRun.navigatedRunID = logID }
.onDisappear { if liveRun.navigatedRunID == logID { liveRun.navigatedRunID = nil } }
} }
// MARK: - Derived // MARK: - Derived
@@ -297,7 +297,8 @@ struct SplitPickerSheet: View {
status: WorkoutStatus.notStarted.rawValue, status: WorkoutStatus.notStarted.rawValue,
createdAt: startDate, createdAt: startDate,
updatedAt: 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 // Hand the id back after the save so the presenter can poll the cache and