Files
workouts/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift
T
rzen e8b1cb800d Give the exercise run screen its own chrome, rolling digits, and a true resume
The run screen now hides the navigation bar in both orientations and draws
its own header — back chevron top-left, exercise name (plus library name
when renamed) in large type where the toolbar was, previewing the next
exercise during a between-exercise rest. Work and rest counters are pinned
to the exact vertical center of the timer half via equal flexible bands,
with the adjust pill moved to an overlay so complications can't shift them.
Counter digits are now state-driven and roll through numericText
transitions (work count-up, rest countdown, and the Done button count).

Reopening an in-progress exercise now resumes its timer from the durable
timestamps instead of restarting at zero: no sets done anchors the
stopwatch to startedAt; mid-rest lands back on the rest page with the
countdown still running off its true window; past the window the next
set's stopwatch counts from the rest's computed end. The resume page is
computed at init because the paged TabView must initialize on it — a
post-layout backward jump wedges the pager, which then ignores the next
animated programmatic advance.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
2026-07-14 20:56:18 -04:00

105 lines
4.3 KiB
Swift

//
// LiveRunCoverView.swift
// Workouts
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
/// Full-screen surface shown on a propped-up iPhone while the Apple Watch is running a live
/// exercise (see `LiveProgress`). Rather than a read-only mirror, this presents the iPhone's
/// real `ExerciseProgressView` driver, seeded to the watch's current page — so either device
/// can drive the run and the other follows:
///
/// • Incoming watch frames jump this driver's page (it never re-broadcasts them).
/// • A human transition *here* broadcasts to the watch and persists durably via `SyncEngine`,
/// exactly as the watch's own driver does.
///
/// Auto-advances (rest / timed-work end) are never sent either way — both devices reach them
/// independently off the shared wall-clock anchors carried in each frame.
struct LiveRunCoverView: View {
/// The frame that triggered presentation — fixes which workout/log this cover drives.
let frame: LiveProgress
/// Dismiss + suppress re-presentation of this run (the user tapped close).
let onClose: () -> Void
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Environment(LiveRunState.self) private var liveRun
@Environment(\.modelContext) private var modelContext
/// The live workout entity, observed so durable updates the watch drove flow back into our
/// working copy. Filtered to just this run's id.
@Query private var workouts: [Workout]
/// Working copy the driver mutates; resolved from the cache on appear. `nil` until then.
@State private var doc: WorkoutDocument?
init(frame: LiveProgress, onClose: @escaping () -> Void) {
self.frame = frame
self.onClose = onClose
let workoutID = frame.workoutID
_workouts = Query(filter: #Predicate<Workout> { $0.id == workoutID })
}
private var workout: Workout? { workouts.first }
/// The latest frame to follow, scoped to this cover's run.
private var incomingFrame: LiveProgress? {
liveRun.current.flatMap { $0.logID == frame.logID ? $0 : nil }
}
var body: some View {
NavigationStack {
Group {
if let binding = Binding($doc) {
ExerciseProgressView(
doc: binding,
logID: frame.logID,
onChange: { persist() },
onLive: { services.watchBridge.sendLiveProgress($0) },
onLiveEnded: {
services.watchBridge.sendLiveEnded(workoutID: frame.workoutID, logID: frame.logID)
},
onActivity: { services.liveActivity.update($0) },
onActivityEnded: { services.liveActivity.end() },
incomingFrame: incomingFrame,
// The run screen hides the nav bar (and this toolbar's close button
// with it); its own header hosts the close action instead.
closeSymbol: "xmark",
onClose: onClose
)
} else {
// The workout hasn't resolved from the cache yet (or has vanished).
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(action: onClose) {
Image(systemName: "xmark")
}
.accessibilityLabel("Close")
}
}
}
.onAppear {
if doc == nil, let workout { doc = WorkoutDocument(from: workout) }
}
.onChange(of: workout?.updatedAt) { _, _ in
// Absorb the durable progress the watch drove (currentStateIndex / status). The
// live *page* is driven by frames, not this — so re-seeding the doc is safe.
if let workout { doc = WorkoutDocument(from: workout) }
}
}
private func persist() {
guard let doc else { return }
let snapshot = doc
Task { await sync.save(workout: snapshot) }
}
}