diff --git a/CHANGELOG.md b/CHANGELOG.md index e5bd8da..e3d5209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ **July 2026** +A new Auto-Advance option lets a split flow hands-free from one exercise to the next, resting automatically between them. + +Each split can now set its own rest time, overriding the app-wide default in Settings. + +The Morning Wake-Up starter split is now a timed routine that auto-advances through every move. + Tap the speaker on any library exercise to hear its full instructions read aloud. Turn on Speak Exercise Cues in Settings to hear each exercise's setup and form cues spoken automatically when you start it, hands-free. diff --git a/README.md b/README.md index d23eabd..1d67f79 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,14 @@ your own iCloud Drive. ## Key Features - **Workout splits** — organize exercises into reusable routines with custom - colors, icons, and an activity type (strength, HIIT, cardio, …). Built-in - starter splits (Upper Body / Core / Lower Body - machine routines, plus an equipment-free **Bodyweight Core** circuit with its - own warm-up) appear automatically on first launch; editing one makes it your - own copy, and deleted starters can be restored from Settings. + colors, icons, and an activity type (strength, HIIT, cardio, …). Each split can + set its own rest time (overriding the app-wide default) and turn on **Auto-Advance** + to flow hands-free from one exercise to the next, resting automatically between them + (the **Morning Wake-Up** starter split ships this way). Built-in starter splits + (Upper Body / Core / Lower Body machine routines, plus an equipment-free + **Bodyweight Core** circuit with its own warm-up) appear automatically on first + launch; editing one makes it your own copy, and deleted starters can be restored + from Settings. - **Exercise library** — a bundled library of 47 exercises to populate your splits, authored in `Exercise Library/` with per-exercise setup, cues, mistakes, progressions, and an anatomically-rigged 3D stick-figure visual diff --git a/Scripts/generate_starter_splits.swift b/Scripts/generate_starter_splits.swift index 8b31802..0ccc64e 100644 --- a/Scripts/generate_starter_splits.swift +++ b/Scripts/generate_starter_splits.swift @@ -66,6 +66,8 @@ struct SplitDocument: Codable { var updatedAt: Date var exercises: [ExerciseDocument] var activityType: Int? + var restSeconds: Int? = nil + var autoAdvance: Bool? = nil } // MARK: - Seed data (canonical source for Workouts/Resources/StarterSplits/*.json) @@ -74,7 +76,7 @@ struct SplitDocument: Codable { // (empty = nothing recorded yet), which lights up the machine-settings UI without // the user having to flip the editor toggle first. struct Ex { let name: String; var weight: Double = 0; var sets = 4; var reps = 10; var load = 1; var dur = 0; var machine = false } -struct Sp { let name: String; let color: String; let icon: String; let activity: Int; let ex: [Ex] } +struct Sp { let name: String; let color: String; let icon: String; let activity: Int; var restSeconds: Int? = nil; var autoAdvance: Bool? = nil; let ex: [Ex] } let seeds: [Sp] = [ Sp(name: "Upper Body", color: "blue", icon: "figure.strengthtraining.traditional", activity: 0, ex: [ @@ -139,16 +141,16 @@ let seeds: [Sp] = [ Ex(name: "Glute Bridge", sets: 2, reps: 10, load: 0), Ex(name: "Standing Calf Raise", sets: 2, reps: 12, load: 0), ]), - Sp(name: "Morning Wake-Up", color: "yellow", icon: "sun.max.fill", activity: 1, ex: [ + Sp(name: "Morning Wake-Up", color: "yellow", icon: "sun.max.fill", activity: 1, restSeconds: 15, autoAdvance: true, ex: [ Ex(name: "March in Place", sets: 1, reps: 0, load: 2, dur: 45), - Ex(name: "Neck Rolls", sets: 1, reps: 5, load: 0), - Ex(name: "Arm Circles", sets: 1, reps: 10, load: 0), - Ex(name: "Torso Twist", sets: 1, reps: 10, load: 0), - Ex(name: "Standing Side Bend", sets: 1, reps: 8, load: 0), - Ex(name: "Hip Circles", sets: 1, reps: 8, load: 0), - Ex(name: "Leg Swings", sets: 1, reps: 10, load: 0), - Ex(name: "Standing Calf Raise", sets: 1, reps: 12, load: 0), - Ex(name: "Standing Forward Fold", sets: 1, reps: 0, load: 2, dur: 30), + Ex(name: "Neck Rolls", sets: 1, reps: 0, load: 2, dur: 45), + Ex(name: "Arm Circles", sets: 1, reps: 0, load: 2, dur: 45), + Ex(name: "Torso Twist", sets: 1, reps: 0, load: 2, dur: 45), + Ex(name: "Standing Side Bend", sets: 1, reps: 0, load: 2, dur: 45), + Ex(name: "Hip Circles", sets: 1, reps: 0, load: 2, dur: 45), + Ex(name: "Leg Swings", sets: 1, reps: 0, load: 2, dur: 45), + Ex(name: "Standing Calf Raise", sets: 1, reps: 0, load: 2, dur: 45), + Ex(name: "Standing Forward Fold", sets: 1, reps: 0, load: 2, dur: 45), ]), Sp(name: "Morning Mobility", color: "pink", icon: "figure.yoga", activity: 1, ex: [ Ex(name: "Cat-Cow", sets: 1, reps: 10, load: 0), @@ -201,7 +203,8 @@ for (order, sp) in seeds.enumerated() { loadType: e.load, durationSeconds: e.dur, machineSettings: e.machine ? [] : nil) }, - activityType: sp.activity) + activityType: sp.activity, + restSeconds: sp.restSeconds, autoAdvance: sp.autoAdvance) let data = try encoder.encode(doc) let file = outDir.appendingPathComponent("\(sp.name).split.json") try data.write(to: file) diff --git a/Shared/Model/Documents.swift b/Shared/Model/Documents.swift index 6715055..bfe945e 100644 --- a/Shared/Model/Documents.swift +++ b/Shared/Model/Documents.swift @@ -29,6 +29,12 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable { /// older app dropping it on rewrite (reverting to the default) is preferable to /// quarantining the user's whole routine. var activityType: Int? + /// Per-split rest length (seconds) and flow-mode toggle. Both optional and + /// deliberately NOT schema-bumped, same rationale as `activityType`: an older + /// app dropping them on rewrite reverts to the global default rest / no-flow, + /// which is preferable to quarantining the user's whole routine. + var restSeconds: Int? = nil + var autoAdvance: Bool? = nil // Bumped 1→2 when the weight-reminder fields (`weightLastUpdated`, // `weightReminderWeeks`) and `category` were removed and `machineSettings` was @@ -103,6 +109,13 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable { /// period. See `WorkoutMergePlanner`. var deletedLogIDs: [String: Date]? = nil + /// Snapshot of the split's rest length / flow flag at plan time (a running + /// workout has no live link to its split, same reason sets/reps/weight are + /// snapshotted per log). Optional and not schema-bumped, same rationale as + /// `SplitDocument.restSeconds`/`autoAdvance`. + var restSeconds: Int? = nil + var autoAdvance: Bool? = nil + // Bumped 1→2 when `metrics` was added: the captured HR/calorie data is // irreplaceable, so the forward-gate must quarantine these files on older apps // rather than let them strip `metrics` on rewrite. diff --git a/Shared/Model/Entities.swift b/Shared/Model/Entities.swift index 3684838..5c07e04 100644 --- a/Shared/Model/Entities.swift +++ b/Shared/Model/Entities.swift @@ -24,6 +24,8 @@ final class Split { var updatedAt: Date = Date() var jsonRelativePath: String = "" var activityTypeRaw: Int = 0 + var restSeconds: Int? + var autoAdvance: Bool? @Relationship(deleteRule: .cascade, inverse: \Exercise.split) var exercises: [Exercise] = [] @@ -148,6 +150,8 @@ final class Workout { /// phone re-derives them into any `WorkoutDocument` it rebuilds and the merge stays /// deletion-safe across cache round-trips. Phone-authored; see `WorkoutMergePlanner`. var deletedLogIDs: [String: Date]? + var restSeconds: Int? + var autoAdvance: Bool? @Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout) var logs: [WorkoutLog] = [] diff --git a/Shared/Model/Mappers.swift b/Shared/Model/Mappers.swift index 3376bb8..a3e0d65 100644 --- a/Shared/Model/Mappers.swift +++ b/Shared/Model/Mappers.swift @@ -27,7 +27,8 @@ extension SplitDocument { color: split.color, systemImage: split.systemImage, order: split.order, createdAt: split.createdAt, updatedAt: split.updatedAt, exercises: split.exercisesArray.map(ExerciseDocument.init(from:)), - activityType: split.activityTypeRaw) + activityType: split.activityTypeRaw, + restSeconds: split.restSeconds, autoAdvance: split.autoAdvance) } } @@ -50,7 +51,8 @@ extension WorkoutDocument { status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt, logs: workout.logsArray.map(WorkoutLogDocument.init(from:)), metrics: workout.metrics, - deletedLogIDs: workout.deletedLogIDs) + deletedLogIDs: workout.deletedLogIDs, + restSeconds: workout.restSeconds, autoAdvance: workout.autoAdvance) } } @@ -89,6 +91,8 @@ enum CacheMapper { split.updatedAt = doc.updatedAt split.jsonRelativePath = relativePath split.activityTypeRaw = doc.activityType ?? 0 + split.restSeconds = doc.restSeconds + split.autoAdvance = doc.autoAdvance let existing = Dictionary(split.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) var keep = Set() @@ -147,6 +151,8 @@ enum CacheMapper { workout.jsonRelativePath = relativePath workout.metrics = doc.metrics workout.deletedLogIDs = doc.deletedLogIDs + workout.restSeconds = doc.restSeconds + workout.autoAdvance = doc.autoAdvance let existing = Dictionary(workout.logs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) var keep = Set() diff --git a/Workouts Watch App/ContentView.swift b/Workouts Watch App/ContentView.swift index e977c64..638549e 100644 --- a/Workouts Watch App/ContentView.swift +++ b/Workouts Watch App/ContentView.swift @@ -18,7 +18,11 @@ struct ContentView: View { set: { presenting in if !presenting { bridge.muteLive() } } )) { if let frame = bridge.presentable { + // Re-key on the followed log so a flow-mode auto-advance (the driver + // moving to the next exercise) rebuilds the mirror for the new exercise + // instead of leaving a stale one pinned to the previous log. LiveRunCoverView(frame: frame) { bridge.muteLive() } + .id(frame.logID) } } } diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index 0369aad..07bda4f 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -40,6 +40,14 @@ struct ExerciseProgressView: View { let onLive: (LiveProgress) -> Void let onLiveEnded: () -> Void + /// True when this run was auto-advanced into from the previous exercise (flow mode). + /// It suppresses the Ready page and begins the exercise immediately on appear. + let enteredViaFlow: Bool + + /// Invoked at the terminal rest's end in flow mode to hand off to the next exercise; + /// the parent `RunFlowView` swaps the presented log. Nil for a non-advancing run. + var onAdvance: ((String) -> Void)? = nil + /// The latest live-run frame the *phone* sent for this run, to follow when it drives a /// transition (ephemeral; nil when the phone isn't driving). Applying it jumps our page /// without re-broadcasting or re-recording — the originating device owns the durable write. @@ -101,12 +109,14 @@ struct ExerciseProgressView: View { /// also suppresses the Ready page so the index is a plain work/rest cycle offset. private let debugInitialPage: Int? - init(doc: Binding, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, debugInitialPage: Int? = nil) { + init(doc: Binding, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, enteredViaFlow: Bool = false, onAdvance: ((String) -> Void)? = nil, debugInitialPage: Int? = nil) { self._doc = doc self.logID = logID self.onChange = onChange self.onLive = onLive self.onLiveEnded = onLiveEnded + self.enteredViaFlow = enteredViaFlow + self.onAdvance = onAdvance self.incomingFrame = incomingFrame self.debugInitialPage = debugInitialPage @@ -116,10 +126,11 @@ struct ExerciseProgressView: View { _knownStateIndex = State(initialValue: max(0, log?.currentStateIndex ?? 0)) let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue - // The Ready page always leads the flow (except in the screenshot host). A - // not-started run opens on it; an in-progress run opens on its first unfinished - // set and re-asserts that page past the TabView's snap-to-0 on first layout. - let ready = debugInitialPage == nil + // The Ready page always leads the flow (except in the screenshot host, or a + // flow-advanced entry which begins immediately). A not-started run opens on it; an + // in-progress run opens on its first unfinished set and re-asserts that page past + // the TabView's snap-to-0 on first layout. + let ready = debugInitialPage == nil && !enteredViaFlow _startsResumed = State(initialValue: ready && !notStarted) _startsCompleted = State(initialValue: ready && log?.status == WorkoutStatus.completed.rawValue) @@ -135,10 +146,30 @@ struct ExerciseProgressView: View { } /// The **Ready?** page always leads the flow, so a resumed run can swipe back to it - /// (which resets the exercise). Suppressed only for the screenshot host, which pins an - /// explicit page. Derived from the immutable `debugInitialPage`, so it stays stable for - /// the life of the screen — the page-index math below depends on it. - private var showsReady: Bool { debugInitialPage == nil } + /// (which resets the exercise). Suppressed for the screenshot host (which pins an + /// explicit page) and for a flow-advanced entry (which begins immediately). Derived + /// from immutable inputs, so it stays stable for the life of the screen — the + /// page-index math below depends on it. + private var showsReady: Bool { debugInitialPage == nil && !enteredViaFlow } + + /// This split runs as a continuous flow — finishing one exercise rests, then + /// auto-advances into the next — rather than returning to the list per exercise. + private var autoAdvance: Bool { doc.autoAdvance == true } + + /// Rest length for this run: the split's per-split override, else the global default. + /// Governs both between-set rests and (in flow mode) the between-exercise rest. + private var effectiveRest: Int { max(1, doc.restSeconds ?? restSeconds) } + + /// The next not-yet-resolved exercise after this one, in plan order — the hand-off + /// target for flow mode. Nil when this is the last unfinished exercise (→ the run ends). + private var nextUnfinishedLogID: String? { + let sorted = doc.logs.sorted { $0.order < $1.order } + guard let idx = sorted.firstIndex(where: { $0.id == logID }) else { return nil } + return sorted[(idx + 1)...].first { log in + let status = WorkoutStatus(rawValue: log.status) ?? .notStarted + return status == .notStarted || status == .inProgress + }?.id + } /// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`. private var base: Int { showsReady ? 1 : 0 } @@ -292,6 +323,12 @@ struct ExerciseProgressView: View { jumpToResumePage() finishRestore() } + } else if enteredViaFlow { + // Auto-advanced into this exercise (flow mode): no Ready tap — begin the + // exercise and broadcast (via finishRestore) so a mirroring phone follows + // across the exercise boundary. + beginExercise() + finishRestore() } else { // Not-started opens on the Ready page; the screenshot host pins its own. finishRestore() @@ -409,6 +446,12 @@ struct ExerciseProgressView: View { } let cycleIndex = page - base if cycleIndex == cycleCount { + // In flow mode with a next exercise, the terminal page is a between-exercise + // rest (mirrored as a `.rest` frame), not the Finish page. + if autoAdvance && nextUnfinishedLogID != nil { + return frame(.rest, setIndex: max(0, setCount - 1), + end: now.addingTimeInterval(Double(effectiveRest))) + } return frame(.finish, setIndex: max(0, setCount - 1), end: now.addingTimeInterval(Double(max(1, doneCountdownSeconds)))) } else if cycleIndex.isMultiple(of: 2) { @@ -417,7 +460,7 @@ struct ExerciseProgressView: View { return frame(.work, setIndex: set, end: end) } else { let set = (cycleIndex - 1) / 2 // the rest follows this set - return frame(.rest, setIndex: set, end: now.addingTimeInterval(Double(max(1, restSeconds)))) + return frame(.rest, setIndex: set, end: now.addingTimeInterval(Double(effectiveRest))) } } @@ -443,13 +486,35 @@ struct ExerciseProgressView: View { } else { let cycleIndex = index - base if cycleIndex == cycleCount { - // Finish page — confirm Done (auto-fires) or add One More. - FinishPhaseView( - isActive: isActive, - onDone: { completeExercise(); dismiss() }, - onOneMore: addSet, - anchorEnd: anchorEnd - ) + if autoAdvance, nextUnfinishedLogID != nil { + // Flow mode: the terminal page is a between-exercise rest that + // completes this exercise and hands off to the next when it ends. + CountdownPhaseView( + header: "Rest", + tint: .restTimer, + seconds: effectiveRest, + isActive: isActive, + anchorStart: anchorStart, + anchorEnd: anchorEnd + ) { _ in + completeExercise() + // Re-resolve at fire time — the next exercise may have been + // finished elsewhere while we rested. None left ⇒ end the run. + if let next = nextUnfinishedLogID { + onAdvance?(next) + } else { + dismiss() + } + } + } else { + // Finish page — confirm Done (auto-fires) or add One More. + FinishPhaseView( + isActive: isActive, + onDone: { completeExercise(); dismiss() }, + onOneMore: addSet, + anchorEnd: anchorEnd + ) + } } else if cycleIndex.isMultiple(of: 2) { let setNumber = cycleIndex / 2 + 1 if isDuration { @@ -480,7 +545,7 @@ struct ExerciseProgressView: View { CountdownPhaseView( header: "Rest", tint: .restTimer, - seconds: restSeconds, + seconds: effectiveRest, isActive: isActive, anchorStart: anchorStart, anchorEnd: anchorEnd diff --git a/Workouts Watch App/Views/RunFlowView.swift b/Workouts Watch App/Views/RunFlowView.swift new file mode 100644 index 0000000..b9e3b53 --- /dev/null +++ b/Workouts Watch App/Views/RunFlowView.swift @@ -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, 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 } + } +} diff --git a/Workouts Watch App/Views/WorkoutLogListView.swift b/Workouts Watch App/Views/WorkoutLogListView.swift index b06636e..eb66807 100644 --- a/Workouts Watch App/Views/WorkoutLogListView.swift +++ b/Workouts Watch App/Views/WorkoutLogListView.swift @@ -114,18 +114,17 @@ struct WorkoutLogListView: View { } } .navigationDestination(item: $selectedLogID) { logID in - ExerciseProgressView( + // RunFlowView hosts the run and, in a flow-mode split, auto-advances across + // exercises. It owns the live-run mirror wiring (incoming-frame filter, + // `navigatedRunID`, and the parameterized live-ended signal) since the on-screen + // log advances during a flowing run. + RunFlowView( doc: $doc, - logID: logID, + startLogID: logID, onChange: { bridge.update(workout: doc) }, onLive: { bridge.sendLiveProgress($0) }, - onLiveEnded: { bridge.sendLiveEnded(workoutID: doc.id, logID: logID) }, - // Follow the phone when it drives this same run from its mirror. - incomingFrame: bridge.liveIncoming.flatMap { $0.logID == logID ? $0 : nil } + sendLiveEnded: { lid in bridge.sendLiveEnded(workoutID: doc.id, logID: lid) } ) - // We're driving this run inline — suppress the follower cover for it. - .onAppear { bridge.navigatedRunID = logID } - .onDisappear { if bridge.navigatedRunID == logID { bridge.navigatedRunID = nil } } } .sheet(isPresented: $showingExercisePicker) { ExercisePickerView(exercises: availableExercises) { exercise in diff --git a/Workouts/ContentView.swift b/Workouts/ContentView.swift index eb3754b..4f814da 100644 --- a/Workouts/ContentView.swift +++ b/Workouts/ContentView.swift @@ -19,7 +19,11 @@ struct ContentView: View { set: { presenting in if !presenting { liveRun.mute() } } )) { if let frame = liveRun.presentable { + // Re-key on the followed log so a flow-mode auto-advance (the driver + // moving to the next exercise) rebuilds the mirror for the new exercise + // instead of leaving a stale one pinned to the previous log. LiveRunCoverView(frame: frame) { liveRun.mute() } + .id(frame.logID) } } } diff --git a/Workouts/Resources/StarterSplits/Morning Wake-Up.split.json b/Workouts/Resources/StarterSplits/Morning Wake-Up.split.json index 0b330cd..121b59a 100644 --- a/Workouts/Resources/StarterSplits/Morning Wake-Up.split.json +++ b/Workouts/Resources/StarterSplits/Morning Wake-Up.split.json @@ -1,5 +1,6 @@ { "activityType" : 1, + "autoAdvance" : true, "color" : "yellow", "createdAt" : "2020-01-01T00:00:00Z", "exercises" : [ @@ -14,77 +15,77 @@ "weight" : 0 }, { - "durationSeconds" : 0, + "durationSeconds" : 45, "id" : "01DXF6DT0090FANSSPTBBYFG08", - "loadType" : 0, + "loadType" : 2, "name" : "Neck Rolls", "order" : 1, - "reps" : 5, + "reps" : 0, "sets" : 1, "weight" : 0 }, { - "durationSeconds" : 0, + "durationSeconds" : 45, "id" : "01DXF6DT00GKZKMRKHQ7AARGWG", - "loadType" : 0, + "loadType" : 2, "name" : "Arm Circles", "order" : 2, - "reps" : 10, + "reps" : 0, "sets" : 1, "weight" : 0 }, { - "durationSeconds" : 0, + "durationSeconds" : 45, "id" : "01DXF6DT00KG2640BBEXTTFNM3", - "loadType" : 0, + "loadType" : 2, "name" : "Torso Twist", "order" : 3, - "reps" : 10, + "reps" : 0, "sets" : 1, "weight" : 0 }, { - "durationSeconds" : 0, + "durationSeconds" : 45, "id" : "01DXF6DT005JW5BTSHWY54S6P3", - "loadType" : 0, + "loadType" : 2, "name" : "Standing Side Bend", "order" : 4, - "reps" : 8, + "reps" : 0, "sets" : 1, "weight" : 0 }, { - "durationSeconds" : 0, + "durationSeconds" : 45, "id" : "01DXF6DT004CYSBPP0KYDMJMMG", - "loadType" : 0, + "loadType" : 2, "name" : "Hip Circles", "order" : 5, - "reps" : 8, + "reps" : 0, "sets" : 1, "weight" : 0 }, { - "durationSeconds" : 0, + "durationSeconds" : 45, "id" : "01DXF6DT00TSP140VH9JYVHS15", - "loadType" : 0, + "loadType" : 2, "name" : "Leg Swings", "order" : 6, - "reps" : 10, + "reps" : 0, "sets" : 1, "weight" : 0 }, { - "durationSeconds" : 0, + "durationSeconds" : 45, "id" : "01DXF6DT00J0C4EADB3VRKEJB2", - "loadType" : 0, + "loadType" : 2, "name" : "Standing Calf Raise", "order" : 7, - "reps" : 12, + "reps" : 0, "sets" : 1, "weight" : 0 }, { - "durationSeconds" : 30, + "durationSeconds" : 45, "id" : "01DXF6DT00BMMGNKB4B6TQ5E59", "loadType" : 2, "name" : "Standing Forward Fold", @@ -97,6 +98,7 @@ "id" : "01DXF6DT00FJ5F1HRENQPC1DTG", "name" : "Morning Wake-Up", "order" : 8, + "restSeconds" : 15, "schemaVersion" : 3, "systemImage" : "sun.max.fill", "updatedAt" : "2020-01-01T00:00:00Z" diff --git a/Workouts/Views/Exercises/ExerciseListView.swift b/Workouts/Views/Exercises/ExerciseListView.swift index c58ec7a..50fef0f 100644 --- a/Workouts/Views/Exercises/ExerciseListView.swift +++ b/Workouts/Views/Exercises/ExerciseListView.swift @@ -214,7 +214,8 @@ struct ExerciseListView: View { status: WorkoutStatus.notStarted.rawValue, createdAt: startDate, updatedAt: startDate, - logs: logs + logs: logs, + restSeconds: split.restSeconds, autoAdvance: split.autoAdvance ) Task { await sync.save(workout: doc) diff --git a/Workouts/Views/Splits/SplitAddEditView.swift b/Workouts/Views/Splits/SplitAddEditView.swift index 34ab075..897fed7 100644 --- a/Workouts/Views/Splits/SplitAddEditView.swift +++ b/Workouts/Views/Splits/SplitAddEditView.swift @@ -29,6 +29,9 @@ struct SplitAddEditView: View { @State private var color: String = "indigo" @State private var systemImage: String = "dumbbell.fill" @State private var activityType: WorkoutActivityType = .traditionalStrength + @State private var autoAdvance: Bool = false + @State private var restOverrideEnabled: Bool = false + @State private var restSecondsValue: Int = 45 @State private var showingIconPicker: Bool = false @State private var showingDeleteConfirmation: Bool = false @@ -42,6 +45,9 @@ struct SplitAddEditView: View { _color = State(initialValue: split.color) _systemImage = State(initialValue: split.systemImage) _activityType = State(initialValue: split.activityTypeEnum) + _autoAdvance = State(initialValue: split.autoAdvance ?? false) + _restOverrideEnabled = State(initialValue: split.restSeconds != nil) + _restSecondsValue = State(initialValue: split.restSeconds ?? 45) } } @@ -111,6 +117,25 @@ struct SplitAddEditView: View { Text("Determines how this workout is categorized in Apple Health and how it credits your Activity rings.") } + Section { + Toggle("Auto-Advance Exercises", isOn: $autoAdvance) + + Toggle("Custom Rest Time", isOn: $restOverrideEnabled) + if restOverrideEnabled { + Stepper(value: $restSecondsValue, in: 10...180, step: 5) { + HStack { + Text("Rest Time") + Spacer() + Text("\(restSecondsValue)s").foregroundColor(.secondary) + } + } + } + } header: { + Text("Rest & Pacing") + } footer: { + Text("Auto-Advance flows from one exercise to the next with a rest between, so you can run the whole split hands-free. Custom Rest Time sets this split's rest — used between sets, and between exercises when auto-advancing; otherwise the Settings default applies.") + } + if let split = split { Section(header: Text("Exercises")) { NavigationLink { @@ -178,6 +203,8 @@ struct SplitAddEditView: View { doc.color = color doc.systemImage = systemImage doc.activityType = activityType.rawValue + doc.restSeconds = restOverrideEnabled ? restSecondsValue : nil + doc.autoAdvance = autoAdvance ? true : nil doc.updatedAt = Date() Task { await sync.save(split: doc) } } else { @@ -193,7 +220,9 @@ struct SplitAddEditView: View { createdAt: Date(), updatedAt: Date(), exercises: [], - activityType: activityType.rawValue + activityType: activityType.rawValue, + restSeconds: restOverrideEnabled ? restSecondsValue : nil, + autoAdvance: autoAdvance ? true : nil ) Task { await sync.save(split: doc) } } diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index c5772b2..6610f57 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -38,6 +38,16 @@ struct ExerciseProgressView: View { let logID: String let onChange: () -> Void + /// True when this run was auto-advanced into from the previous exercise (flow mode). + /// It suppresses the Ready page and begins the exercise immediately on appear — the + /// whole point of a hands-free flowing split. + let enteredViaFlow: Bool + + /// Invoked at the terminal rest's end in flow mode to hand off to the next exercise; + /// the parent `RunFlowView` swaps the presented log. Nil for a non-advancing run + /// (the terminal page is then the normal Finish page that dismisses). + var onAdvance: ((String) -> Void)? = nil + /// Broadcasts the current flow position so the watch (if it has this run open) can follow /// it live (ephemeral; see `LiveProgress`). `onLiveEnded` fires when we leave the flow. /// Only *human* transitions are broadcast — an auto-advance (rest/timed-work end) isn't, @@ -135,7 +145,7 @@ struct ExerciseProgressView: View { /// `startsCompleted`. @State private var startsSkipped: Bool - init(doc: Binding, 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, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, speechAnnouncer: SpeechAnnouncer? = nil, enteredViaFlow: Bool = false, onAdvance: ((String) -> Void)? = nil) { self._doc = doc self.logID = logID self.onChange = onChange @@ -145,6 +155,8 @@ struct ExerciseProgressView: View { self.onActivityEnded = onActivityEnded self.incomingFrame = incomingFrame self.speechAnnouncer = speechAnnouncer + self.enteredViaFlow = enteredViaFlow + self.onAdvance = onAdvance let log = doc.wrappedValue.logs.first { $0.id == logID } let sets = max(1, log?.sets ?? 1) @@ -159,7 +171,8 @@ struct ExerciseProgressView: View { _startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue) _startsSkipped = State(initialValue: log?.status == WorkoutStatus.skipped.rawValue) - let base = 1 + // A flow-advanced entry has no Ready page (base 0), so page 0 is its first work set. + let base = enteredViaFlow ? 0 : 1 // Resume on the first unfinished set's work page (clamped to the last set). let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1) let resume = base + completed * 2 @@ -170,10 +183,29 @@ struct ExerciseProgressView: View { doc.logs.first { $0.id == logID } } - /// The **Ready?** page always leads the flow, so a resumed run can swipe back to it - /// (which resets the exercise). (The watch additionally suppresses it for its - /// screenshot host; the iPhone has no such host, so it's always shown.) - private var showsReady: Bool { true } + /// The **Ready?** page leads the flow so a resumed run can swipe back to it (which + /// resets the exercise). A flow-advanced entry skips it — the exercise begins on + /// appear, hands-free. + private var showsReady: Bool { !enteredViaFlow } + + /// This split runs as a continuous flow — finishing one exercise rests, then + /// auto-advances into the next — rather than returning to the list per exercise. + private var autoAdvance: Bool { doc.autoAdvance == true } + + /// Rest length for this run: the split's per-split override, else the global default. + /// Governs both between-set rests and (in flow mode) the between-exercise rest. + private var effectiveRest: Int { max(1, doc.restSeconds ?? restSeconds) } + + /// The next not-yet-resolved exercise after this one, in plan order — the hand-off + /// target for flow mode. Nil when this is the last unfinished exercise (→ the run ends). + private var nextUnfinishedLogID: String? { + let sorted = doc.logs.sorted { $0.order < $1.order } + guard let idx = sorted.firstIndex(where: { $0.id == logID }) else { return nil } + return sorted[(idx + 1)...].first { log in + let status = WorkoutStatus(rawValue: log.status) ?? .notStarted + return status == .notStarted || status == .inProgress + }?.id + } /// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`. private var base: Int { showsReady ? 1 : 0 } @@ -347,6 +379,14 @@ struct ExerciseProgressView: View { jumpToResumePage() finishRestore() } + } else if enteredViaFlow { + // Auto-advanced into this exercise (flow mode): no Ready tap — begin the + // exercise, then let finishRestore broadcast its first work frame so a + // mirroring device follows across the exercise boundary (a discrete hand-off, + // not a shared-timer auto-advance it could reach on its own). + beginExercise() + announceCue() + finishRestore() } else { // Not-started opens on the Ready page. finishRestore() @@ -492,6 +532,12 @@ struct ExerciseProgressView: View { } let cycleIndex = page - base if cycleIndex == cycleCount { + // In flow mode with a next exercise, the terminal page is a between-exercise + // rest (mirrored as a `.rest` frame), not the Finish page. + if autoAdvance && nextUnfinishedLogID != nil { + return frame(.rest, setIndex: max(0, setCount - 1), + end: now.addingTimeInterval(Double(effectiveRest))) + } return frame(.finish, setIndex: max(0, setCount - 1), end: now.addingTimeInterval(Double(max(1, doneCountdownSeconds)))) } else if cycleIndex.isMultiple(of: 2) { @@ -500,7 +546,7 @@ struct ExerciseProgressView: View { return frame(.work, setIndex: set, end: end) } else { let set = (cycleIndex - 1) / 2 // the rest follows this set - return frame(.rest, setIndex: set, end: now.addingTimeInterval(Double(max(1, restSeconds)))) + return frame(.rest, setIndex: set, end: now.addingTimeInterval(Double(effectiveRest))) } } @@ -516,15 +562,40 @@ struct ExerciseProgressView: View { } else { let cycleIndex = index - base if cycleIndex == cycleCount { - // Finish page — confirm Done (auto-fires) or add One More. - VStack(spacing: 0) { - FinishPhaseView( - isActive: isActive, - onDone: { completeExercise(); dismiss() }, - onOneMore: addSet, - anchorEnd: anchorEnd - ) - adjustPill + if autoAdvance, nextUnfinishedLogID != nil { + // Flow mode: the terminal page is a between-exercise rest that + // completes this exercise and hands off to the next when it ends. + VStack(spacing: 0) { + CountdownPhaseView( + header: "Rest", + tint: .restTimer, + seconds: effectiveRest, + isActive: isActive, + anchorStart: anchorStart, + anchorEnd: anchorEnd + ) { _ in + completeExercise() + // Re-resolve at fire time — the next exercise may have been + // finished elsewhere while we rested. None left ⇒ end the run. + if let next = nextUnfinishedLogID { + onAdvance?(next) + } else { + dismiss() + } + } + adjustPill + } + } else { + // Finish page — confirm Done (auto-fires) or add One More. + VStack(spacing: 0) { + FinishPhaseView( + isActive: isActive, + onDone: { completeExercise(); dismiss() }, + onOneMore: addSet, + anchorEnd: anchorEnd + ) + adjustPill + } } } else if cycleIndex.isMultiple(of: 2) { let setNumber = cycleIndex / 2 + 1 @@ -557,7 +628,7 @@ struct ExerciseProgressView: View { CountdownPhaseView( header: "Rest", tint: .restTimer, - seconds: restSeconds, + seconds: effectiveRest, isActive: isActive, anchorStart: anchorStart, anchorEnd: anchorEnd diff --git a/Workouts/Views/WorkoutLogs/RunFlowView.swift b/Workouts/Views/WorkoutLogs/RunFlowView.swift new file mode 100644 index 0000000..85f5b58 --- /dev/null +++ b/Workouts/Views/WorkoutLogs/RunFlowView.swift @@ -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, 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 } + } +} diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index 495a1ab..afe68ee 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -240,19 +240,20 @@ struct WorkoutLogListView: View { /// (so the propped-phone mirror cover doesn't stack on top of it). @ViewBuilder private func progressView(logID: String) -> some View { - ExerciseProgressView( + // RunFlowView hosts the run and, in a flow-mode split, auto-advances across + // exercises. It owns the live-run mirror wiring (incoming frame filter, + // `navigatedRunID`, and the parameterized live-ended signal) since the on-screen + // log advances during a flowing run. + RunFlowView( doc: $doc, - logID: logID, + startLogID: logID, onChange: { save() }, onLive: { services.watchBridge.sendLiveProgress($0) }, - onLiveEnded: { services.watchBridge.sendLiveEnded(workoutID: doc.id, logID: logID) }, + sendLiveEnded: { lid in services.watchBridge.sendLiveEnded(workoutID: doc.id, logID: lid) }, onActivity: { services.liveActivity.update($0) }, onActivityEnded: { services.liveActivity.end() }, - incomingFrame: liveRun.current.flatMap { $0.logID == logID ? $0 : nil }, speechAnnouncer: services.speechAnnouncer ) - .onAppear { liveRun.navigatedRunID = logID } - .onDisappear { if liveRun.navigatedRunID == logID { liveRun.navigatedRunID = nil } } } // MARK: - Derived diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift index 682aac1..ae20568 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift @@ -297,7 +297,8 @@ struct SplitPickerSheet: View { status: WorkoutStatus.notStarted.rawValue, createdAt: startDate, updatedAt: startDate, - logs: logs + logs: logs, + restSeconds: split.restSeconds, autoAdvance: split.autoAdvance ) // Hand the id back after the save so the presenter can poll the cache and