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
101 lines
4.0 KiB
Swift
101 lines
4.0 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
|
|
)
|
|
} 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) }
|
|
}
|
|
}
|