Add a Live Activity for the running workout

A new iOS widget extension shows the active exercise, its phase, and the
work/rest countdown on the lock screen and in the Dynamic Island, driven
by the run flow's live frames so locking the phone mid-set keeps the
timer. The activity is seeded on open, refreshed on every page settle,
dismissed when the flow is left, and cleared on next launch if stranded.
Unifies the build-info stamping across all targets via a YAML anchor.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
2026-07-08 08:02:34 -04:00
parent e04eb83e70
commit df3eac9d5f
11 changed files with 569 additions and 18 deletions
@@ -45,6 +45,15 @@ struct ExerciseProgressView: View {
let onLive: (LiveProgress) -> Void
let onLiveEnded: () -> Void
/// Reflects the run's current position onto the iOS Live Activity (lock screen + Dynamic
/// Island). Unlike `onLive` (watch, *human* transitions only), this fires on **every** page
/// settle human, auto-advance, or a followed watch frame plus the initial page, since the
/// Live Activity must track the current phase and countdown no matter who drove it.
/// `onActivityEnded` fires when the flow is left, so the activity can be dismissed. Both
/// default to no-ops, so the in-list driver only wires them where an activity is wanted.
let onActivity: (LiveProgress) -> Void
let onActivityEnded: () -> 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.
@@ -96,12 +105,14 @@ struct ExerciseProgressView: View {
/// `startsCompleted`.
@State private var startsSkipped: Bool
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = 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) {
self._doc = doc
self.logID = logID
self.onChange = onChange
self.onLive = onLive
self.onLiveEnded = onLiveEnded
self.onActivity = onActivity
self.onActivityEnded = onActivityEnded
self.incomingFrame = incomingFrame
let log = doc.wrappedValue.logs.first { $0.id == logID }
@@ -266,6 +277,9 @@ struct ExerciseProgressView: View {
}
broadcastLive(for: newPage)
}
// The Live Activity tracks every settle (all causes), not just human ones a rest
// that auto-advances or a page the watch drove must still refresh the lock screen.
emitActivity(for: newPage)
}
.onChange(of: incomingFrame) { _, frame in
if let frame { applyIncoming(frame) }
@@ -288,8 +302,10 @@ struct ExerciseProgressView: View {
}
}
.onDisappear {
// Leaving the flow (back / done) stop the watch from following.
// Leaving the flow (back / done) stop the watch from following and dismiss the
// Live Activity (it lingers briefly on a completed run before auto-dismissing).
onLiveEnded()
onActivityEnded()
}
}
@@ -314,6 +330,21 @@ struct ExerciseProgressView: View {
} else {
broadcastLive(for: currentPage)
}
// Seed the Live Activity with the opening page (a resumed run starts it here; a
// not-started run sits on Ready, which the controller ignores until the run begins).
emitActivity(for: currentPage)
}
/// Reflect the current flow position onto the Live Activity. Built from the same
/// `liveSnapshot` the watch broadcast uses, but honoring the remote anchors when the page was
/// reached by a watch frame, so the lock-screen countdown lines up with the mirror.
private func emitActivity(for page: Int) {
guard var snapshot = liveSnapshot(for: page) else { return }
if page == remoteAnchorPage {
if let start = remoteAnchorStart { snapshot.phaseStart = start }
snapshot.phaseEnd = remoteAnchorEnd
}
onActivity(snapshot)
}
/// Push the current flow position to the watch. The anchor is stamped *now* the page
@@ -63,6 +63,8 @@ struct LiveRunCoverView: View {
onLiveEnded: {
services.watchBridge.sendLiveEnded(workoutID: frame.workoutID, logID: frame.logID)
},
onActivity: { services.liveActivity.update($0) },
onActivityEnded: { services.liveActivity.end() },
incomingFrame: incomingFrame
)
} else {
@@ -246,6 +246,8 @@ struct WorkoutLogListView: View {
onChange: { save() },
onLive: { services.watchBridge.sendLiveProgress($0) },
onLiveEnded: { services.watchBridge.sendLiveEnded(workoutID: doc.id, logID: logID) },
onActivity: { services.liveActivity.update($0) },
onActivityEnded: { services.liveActivity.end() },
incomingFrame: liveRun.current.flatMap { $0.logID == logID ? $0 : nil }
)
.onAppear { liveRun.navigatedRunID = logID }