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