Make the live run two-way: drive from either device

The propped-up iPhone now runs the real ExerciseProgressView for a live
watch workout instead of a read-only mirror, and the live-run channel is
symmetric — either device can drive the flow and the other follows.

Each page transition is classified human / auto / remote: only human
transitions (swipe, Start, One More, swipe-back reset) are broadcast and
recorded by the actor; auto-advances (rest / timed-work countdown) record
locally but aren't sent, since both devices reach them independently off
the shared wall-clock anchors; an applied remote frame jumps the page
without re-recording or re-broadcasting. That rule is also what stops an
echo loop.

- PhoneConnectivityBridge gains sendLiveProgress/sendLiveEnded (the
  missing phone->watch direction); WatchConnectivityBridge receives
  frames into an observable liveIncoming via a new didReceiveMessage
  route. Both share one increasing per-run version sequence so the
  stale-frame guard works across the two devices' counters.
- Both ExerciseProgressViews gain an incomingFrame input + applyIncoming
  (syncing setCount for a remote One More); the iPhone one gains the
  liveSnapshot/broadcast machinery the watch already had.
- New LiveRunCoverView wraps the real driver for the cover (resolves the
  workout, persists via SyncEngine, wires the live channel + close);
  ContentView presents it; LiveProgressMirrorView is removed.

Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
This commit is contained in:
2026-06-20 22:11:05 -04:00
parent b911818587
commit 8f69497b24
10 changed files with 407 additions and 276 deletions
@@ -23,6 +23,11 @@ final class PhoneConnectivityBridge: NSObject {
private(set) var editingWorkoutID: String?
private(set) var editingSplitID: String?
/// Monotonic sequence stamped on each live-run frame we send. Bumped to stay ahead of
/// any frame we *receive*, so the two devices share one increasing sequence per run and
/// either side can drop a stale / out-of-order delivery (see `LiveProgress.version`).
private var liveVersion = 0
private var context: ModelContext { container.mainContext }
init(container: ModelContainer, syncEngine: SyncEngine, liveRunState: LiveRunState) {
@@ -82,6 +87,34 @@ final class PhoneConnectivityBridge: NSObject {
pushAll()
}
// MARK: - Live run mirror (ephemeral; reachable-only)
/// Broadcast where the run flow currently is, so the watch (if it has this run open) can
/// follow it live. Sent over `sendMessage` only when reachable this is throwaway
/// presence, so there's no guaranteed-delivery fallback (a queued frame would be stale on
/// arrival). Mirrors the watch's `sendLiveProgress`; only *human* transitions are sent.
func sendLiveProgress(_ frame: LiveProgress) {
guard let session, session.activationState == .activated, session.isReachable else { return }
liveVersion += 1
var stamped = frame
stamped.version = liveVersion
session.sendMessage(WCPayload.encodeLiveProgress(stamped), replyHandler: nil, errorHandler: { _ in })
}
/// Tell the watch we left the run flow (the cover closed / the run finished).
func sendLiveEnded(workoutID: String, logID: String) {
guard let session, session.activationState == .activated, session.isReachable else { return }
session.sendMessage(WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID),
replyHandler: nil, errorHandler: { _ in })
}
/// Apply a frame the watch sent. Catch our send counter up to it first, so the next frame
/// we send outranks it and the shared per-run sequence keeps increasing across devices.
private func applyIncomingLive(_ frame: LiveProgress) {
liveVersion = max(liveVersion, frame.version)
liveRunState.apply(frame)
}
/// Parse the (non-Sendable) WC dictionary in the nonisolated delegate context,
/// then hop to the MainActor with only Sendable values.
nonisolated private func route(_ dict: [String: Any]) {
@@ -94,7 +127,7 @@ final class PhoneConnectivityBridge: NSObject {
}
case WCPayload.liveProgressType:
if let frame = WCPayload.decodeLiveProgress(dict) {
Task { @MainActor in self.liveRunState.apply(frame) }
Task { @MainActor in self.applyIncomingLive(frame) }
}
case WCPayload.liveEndedType:
if let logID = dict[WCPayload.lpLogIDKey] as? String {
+3 -2
View File
@@ -12,13 +12,14 @@ struct ContentView: View {
var body: some View {
WorkoutLogsView()
// Prop the phone up and it mirrors a live workout running on the Apple Watch.
// Prop the phone up and it runs the live Apple Watch workout two-way: drive from
// either device and the other follows.
.fullScreenCover(isPresented: Binding(
get: { liveRun.presentable != nil },
set: { presenting in if !presenting { liveRun.mute() } }
)) {
if let frame = liveRun.presentable {
LiveProgressMirrorView(progress: frame) { liveRun.mute() }
LiveRunCoverView(frame: frame) { liveRun.mute() }
}
}
}
@@ -36,14 +36,40 @@ struct ExerciseProgressView: View {
let logID: String
let onChange: () -> Void
/// 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,
/// since the other device reaches it independently off the same wall-clock anchors. Default
/// no-ops, so the in-list driver stays purely durable; the propped-phone cover wires these.
let onLive: (LiveProgress) -> Void
let onLiveEnded: () -> Void
/// The latest live-run frame the *watch* sent for this run, to follow when it drives a
/// transition (ephemeral; nil when the watch isn't driving). Applying it jumps our page
/// without re-broadcasting or re-recording the originating device owns the durable write.
var incomingFrame: LiveProgress?
/// Rest length between sets, shared with the watch via the same defaults key.
@AppStorage("restSeconds") private var restSeconds: Int = 45
/// Auto-Done countdown on the Finish page read so a broadcast frame carries the same
/// end anchor the watch's mirror counts off.
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
/// Planned set count for this run. `One More` bumps it (and the log's `sets`).
@State private var setCount: Int
@State private var currentPage: Int
@State private var didRestorePage = false
/// Why `currentPage` last changed, so the page observer knows whether to broadcast it.
/// A human swipe leaves this `nil` ( treated as human); programmatic moves set it.
@State private var pageChangeCause: PageChangeCause?
/// Highest remote frame version we've applied, so a redelivery doesn't re-jump.
@State private var lastAppliedVersion = 0
private enum PageChangeCause { case auto, remote }
/// True when this run opened on a resumed set (in-progress) rather than the Ready
/// page. Held in `@State` so it stays fixed for the life of the screen the parent
/// list re-inits this view whenever the workout file changes, and this must not flip
@@ -51,10 +77,13 @@ struct ExerciseProgressView: View {
/// the transient TabView snap-to-0, so it isn't reset on open.
@State private var startsResumed: Bool
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void) {
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
self._doc = doc
self.logID = logID
self.onChange = onChange
self.onLive = onLive
self.onLiveEnded = onLiveEnded
self.incomingFrame = incomingFrame
let log = doc.wrappedValue.logs.first { $0.id == logID }
let sets = max(1, log?.sets ?? 1)
@@ -171,15 +200,31 @@ struct ExerciseProgressView: View {
// Ignore page changes until the initial resume settles, so the TabView's
// transient snap-to-0 on first layout can't reset an in-progress run.
guard didRestorePage else { return }
// A deliberate swipe back from the first set to the Ready page wipes the run
// (only the adjacent 10 swipe resets a stray far jump never does); any
// other page records forward progress.
if showsReady && newPage == 0 && oldPage == base {
resetExercise()
} else {
let cause = pageChangeCause
pageChangeCause = nil
switch cause {
case .remote:
// The watch already recorded and owns this transition just follow it.
break
case .auto:
// Rest / timed-work auto-advance: record forward progress, but don't
// broadcast the watch reaches this point on its own synchronized timer.
recordProgress(for: newPage)
case .none:
// A human swipe. Swiping back from the first set to Ready wipes the run
// (only the adjacent 10 swipe resets a stray far jump never does); any
// other page records forward progress. Human transitions are broadcast.
if showsReady && newPage == 0 && oldPage == base {
resetExercise()
} else {
recordProgress(for: newPage)
}
broadcastLive(for: newPage)
}
}
.onChange(of: incomingFrame) { _, frame in
if let frame { applyIncoming(frame) }
}
.onAppear {
guard !didRestorePage else { return }
if startsResumed {
@@ -190,13 +235,17 @@ struct ExerciseProgressView: View {
jumpToResumePage()
Task { @MainActor in
jumpToResumePage()
didRestorePage = true
finishRestore()
}
} else {
// Not-started opens on the Ready page.
didRestorePage = true
finishRestore()
}
}
.onDisappear {
// Leaving the flow (back / done) stop the watch from following.
onLiveEnded()
}
}
/// Move to the resume page without animation, only if we're not already there
@@ -209,6 +258,89 @@ struct ExerciseProgressView: View {
withTransaction(transaction) { currentPage = target }
}
// MARK: - Live mirror
/// Finish the initial-page restore, then either follow an in-progress remote driver or, if
/// we're the one starting the run, announce our position to the watch.
private func finishRestore() {
didRestorePage = true
if let frame = incomingFrame, frame.logID == logID {
applyIncoming(frame)
} else {
broadcastLive(for: currentPage)
}
}
/// Push the current flow position to the watch. The anchor is stamped *now* the page
/// just became active so the watch's mirror timer lines up with this device's.
private func broadcastLive(for page: Int) {
guard let snapshot = liveSnapshot(for: page) else { return }
onLive(snapshot)
}
/// Follow a transition the watch made: jump to the frame's page (matching its set count
/// for a remote One More) without re-broadcasting or re-recording it the watch owns the
/// durable write, which arrives separately over the document sync.
private func applyIncoming(_ frame: LiveProgress) {
guard didRestorePage, frame.logID == logID, frame.version > lastAppliedVersion else { return }
lastAppliedVersion = frame.version
if frame.setCount != setCount { setCount = frame.setCount }
let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
guard target != currentPage else { return }
pageChangeCause = .remote
withAnimation { currentPage = target }
}
/// Inverse of `liveSnapshot`'s pageframe mapping: a frame's phase/set page index.
private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int {
let set = min(max(0, setIndex), max(0, setCount - 1))
switch phase {
case .ready: return showsReady ? 0 : base
case .work: return base + set * 2
case .rest: return base + set * 2 + 1
case .finish: return base + cycleCount
}
}
/// Build the live-run frame for a given page: phase, the set it pertains to, and the
/// wall-clock anchors the watch counts off. Count-down phases (rest, timed work, finish)
/// carry an end anchor; a rep-based work set counts up and leaves it `nil`.
private func liveSnapshot(for page: Int) -> LiveProgress? {
guard let log else { return nil }
let now = Date()
func frame(_ phase: LiveRunPhase, setIndex: Int, end: Date?) -> LiveProgress {
LiveProgress(
workoutID: doc.id,
logID: logID,
exerciseName: log.exerciseName,
phase: phase,
setIndex: setIndex,
setCount: setCount,
detail: detail,
phaseStart: now,
phaseEnd: end,
version: 0
)
}
if showsReady && page == 0 {
return frame(.ready, setIndex: 0, end: nil)
}
let cycleIndex = page - base
if cycleIndex == cycleCount {
return frame(.finish, setIndex: max(0, setCount - 1),
end: now.addingTimeInterval(Double(max(1, doneCountdownSeconds))))
} else if cycleIndex.isMultiple(of: 2) {
let set = cycleIndex / 2
let end = isDuration ? now.addingTimeInterval(Double(workDurationSeconds)) : nil
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))))
}
}
@ViewBuilder
private func page(for index: Int) -> some View {
let isActive = index == currentPage
@@ -268,9 +400,11 @@ struct ExerciseProgressView: View {
}
/// Programmatically move one page right (used by the rest auto-advance), guarding
/// against overrun if the user swiped away in the meantime.
/// against overrun if the user swiped away in the meantime. Tagged `.auto` so the page
/// observer records progress but doesn't broadcast it (the watch auto-advances too).
private func advance(from index: Int) {
guard currentPage == index, index + 1 < totalPages else { return }
pageChangeCause = .auto
currentPage = index + 1
}
@@ -1,241 +0,0 @@
//
// LiveProgressMirrorView.swift
// Workouts
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import UIKit
/// Read-only mirror of the watch's run flow, driven entirely by the latest `LiveProgress`
/// frame. It re-creates the look of the iPhone's own `ExerciseProgressView` Ready / Work /
/// Rest / Finish with the same anchored timers but takes no input: the user drives on the
/// watch, this just reflects it. The timers render off the frame's wall-clock anchors, so they
/// keep ticking smoothly between frames and stay in step with the watch without streaming.
///
/// The phase styling helpers below are intentionally a small standalone copy of the driver
/// flow's, so mirroring can't regress the shipping run experience.
struct LiveProgressMirrorView: View {
let progress: LiveProgress
let onClose: () -> Void
var body: some View {
VStack(spacing: 0) {
header
phaseContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .bottom) {
if let model = dotsModel {
MirrorDots(model: model).padding(.bottom, 8)
}
}
Label("Mirroring Apple Watch", systemImage: "applewatch")
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.bottom, 12)
}
}
private var header: some View {
HStack {
Text(progress.exerciseName)
.font(.headline)
.lineLimit(1)
Spacer()
Button(action: onClose) {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
.symbolRenderingMode(.hierarchical)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.padding()
}
@ViewBuilder
private var phaseContent: some View {
switch progress.phase {
case .ready:
ReadyMirror(summary: readySummary)
case .work:
MirrorTimerLayout(
header: "\(progress.setIndex + 1) of \(progress.setCount)",
footer: progress.detail,
tint: .mirrorWork
) {
if let end = progress.phaseEnd {
// Timed work set counts down.
Text(timerInterval: progress.phaseStart...end, countsDown: true)
} else {
// Rep-based work set counts up.
Text(progress.phaseStart, style: .timer)
}
}
case .rest:
MirrorTimerLayout(header: "Rest", footer: "", tint: .mirrorRest) {
if let end = progress.phaseEnd {
Text(timerInterval: progress.phaseStart...end, countsDown: true)
} else {
Text("0:00")
}
}
case .finish:
FinishMirror(start: progress.phaseStart, end: progress.phaseEnd)
}
}
/// One-line plan summary for the Ready page, e.g. "4 sets × 8 reps".
private var readySummary: String {
let setsText = "\(progress.setCount) set\(progress.setCount == 1 ? "" : "s")"
return progress.detail.isEmpty ? setsText : "\(setsText) × \(progress.detail)"
}
/// Dot-row model matches the driver flow's `workDots` mapping.
private var dotsModel: MirrorDots.Model? {
switch progress.phase {
case .work:
return .init(setCount: progress.setCount, activeSet: progress.setIndex,
restAfterSet: nil, completed: progress.setIndex)
case .rest:
return .init(setCount: progress.setCount, activeSet: nil,
restAfterSet: progress.setIndex, completed: progress.setIndex + 1)
case .ready, .finish:
return nil
}
}
}
// MARK: - Phase Mirrors
private struct ReadyMirror: View {
let summary: String
var body: some View {
VStack(spacing: 14) {
Text("Ready?")
.font(.system(size: 44, weight: .bold, design: .rounded))
if !summary.isEmpty {
Text(summary)
.font(.title3)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct FinishMirror: View {
let start: Date
let end: Date?
var body: some View {
VStack(spacing: 14) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 96))
.foregroundStyle(Color.mirrorWork)
if let end {
Text(timerInterval: start...end, countsDown: true)
.font(.system(size: 40, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.secondary)
}
Text("Finishing on Apple Watch")
.font(.title3)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Shared Layout (standalone copy of the driver flow's styling)
private struct MirrorTimerLayout<Content: View>: View {
let header: String
let footer: String
let tint: Color
@ViewBuilder var timer: Content
var body: some View {
VStack(spacing: 10) {
Text(header)
.font(.title3)
.foregroundStyle(.secondary)
timer
.font(.system(size: 108, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(tint)
.lineLimit(1)
.minimumScaleFactor(0.5)
Text(footer.isEmpty ? " " : footer)
.font(.title3)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct MirrorDots: View {
struct Model: Equatable {
let setCount: Int
let activeSet: Int?
let restAfterSet: Int?
let completed: Int
}
let model: Model
private let dotWidth: CGFloat = 8
private let dashWidth: CGFloat = 20
private let markerHeight: CGFloat = 8
private let gap: CGFloat = 8
private var restGap: CGFloat { gap * 2 }
var body: some View {
HStack(spacing: 0) {
ForEach(0..<model.setCount, id: \.self) { i in
marker(for: i)
if i < model.setCount - 1 {
Color.clear.frame(width: gapWidth(after: i), height: markerHeight)
}
}
}
.animation(.easeInOut(duration: 0.3), value: model)
}
private func marker(for i: Int) -> some View {
let isActive = model.activeSet == i
let isDone = i < model.completed
return Capsule()
.fill(Color.mirrorWork)
.frame(width: isActive ? dashWidth : dotWidth, height: markerHeight)
.opacity(isActive || isDone ? 1 : 0.45)
}
private func gapWidth(after i: Int) -> CGFloat {
model.restAfterSet == i ? restGap : gap
}
}
// MARK: - Phase Colors (matched to the driver flow)
private extension Color {
static let mirrorWork = Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.66, green: 0.45, blue: 0.96, alpha: 1)
: UIColor(red: 0.45, green: 0.18, blue: 0.78, alpha: 1)
})
static let mirrorRest = Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(white: 0.74, alpha: 1)
: UIColor(white: 0.52, alpha: 1)
})
}
@@ -0,0 +1,98 @@
//
// 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)
},
incomingFrame: incomingFrame
)
} 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) }
}
}