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:
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user