Replaces the small target-HR pill on the iPhone run screen with a glanceable big-digit strip between the timer flow and the figure — heart rate (band-tinted with the cue arrow when the run carries a target), HR zone, active calories, and the workout stopwatch. A horizontal band in portrait, a vertical column in landscape (the inverse of the half-and-half split, so it always sits between them). The watch now rides the running calorie total and the HR zone (1-5, computed watch-side where max HR is known) along with each live HR sample; absent keys keep older builds wire-compatible both ways. LiveRunState holds all three under the shared staleness expiry. The screenshot rig seeds believable values so the run capture shows the panel populated.
50 lines
2.3 KiB
Swift
50 lines
2.3 KiB
Swift
import Foundation
|
|
import Observation
|
|
import SwiftData
|
|
|
|
/// Composition root for the watch app. Owns the local SwiftData cache and the
|
|
/// WatchConnectivity bridge. The watch has no iCloud access; all data arrives from
|
|
/// the phone via the bridge.
|
|
@Observable
|
|
@MainActor
|
|
final class WatchAppServices {
|
|
let container: ModelContainer
|
|
let bridge: WatchConnectivityBridge
|
|
|
|
/// The single owner of workout-session termination. Built in `bootstrap` once we have the
|
|
/// delegate's session manager to reference; nil until then.
|
|
private var sessionCoordinator: WorkoutSessionCoordinator?
|
|
|
|
init() {
|
|
let container = WorkoutsModelContainer.make()
|
|
self.container = container
|
|
self.bridge = WatchConnectivityBridge(container: container)
|
|
#if DEBUG
|
|
if ScreenshotSeed.isActive { ScreenshotSeed.populate(container.mainContext) }
|
|
#endif
|
|
}
|
|
|
|
/// Wire the session coordinator to the (delegate-owned) session manager, start the bridge,
|
|
/// and seed the coordinator's baseline from whatever state is already applied. The manager
|
|
/// is passed in rather than owned here so the delegate's `handle(_:)` and the view tree's
|
|
/// `.environment(...)` keep referencing the same instance.
|
|
func bootstrap(sessionManager: WorkoutSessionManager) {
|
|
let coordinator = WorkoutSessionCoordinator(
|
|
sessionManager: sessionManager,
|
|
bridge: bridge,
|
|
context: container.mainContext)
|
|
self.sessionCoordinator = coordinator
|
|
// weak break: the coordinator is held here; the bridge only borrows it.
|
|
bridge.onWorkoutsChanged = { [weak coordinator] in coordinator?.reconcile() }
|
|
// Forward each live sample (HR + calories + zone) to a mirroring phone
|
|
// (best-effort, display-only). No cycle: the bridge never references the
|
|
// session manager back.
|
|
sessionManager.onHeartRateSample = { [bridge] sample in bridge.sendLiveSample(sample) }
|
|
bridge.activate()
|
|
// Seed `previouslyActiveIDs` from whatever the bridge already applied at launch, so the
|
|
// first real change is measured against a correct baseline (and the launch race — a
|
|
// running session before the run doc arrives — resolves to `.none`, never a discard).
|
|
coordinator.reconcile()
|
|
}
|
|
}
|