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:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 1→2 when the weight-reminder fields (`weightLastUpdated`,
|
// Bumped 1→2 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 1→2 when `metrics` was added: the captured HR/calorie data is
|
// Bumped 1→2 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.
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user