Add a live status panel between the run screen's halves

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.
This commit is contained in:
2026-07-16 18:54:39 -04:00
parent a0f14f46e1
commit aa23d7baa7
10 changed files with 167 additions and 57 deletions
+15 -2
View File
@@ -40,17 +40,30 @@ final class LiveRunState {
/// the watch stops sending (session ended, unreachable).
private(set) var heartRate: Double?
/// Running active-calorie total for the watch session, riding along with each HR
/// sample. Nil until the first energy sample (or from an older watch build).
private(set) var activeEnergyKcal: Double?
/// HR zone (15) the current rate falls in, computed on the watch (the only place
/// that knows max HR). Nil when the wearer's age is unknown.
private(set) var hrZone: Int?
private var heartRateExpiry: Task<Void, Never>?
private static let hrStaleAfter: Duration = .seconds(30)
/// Apply an incoming heart-rate sample and (re)arm its staleness expiry.
func applyHeartRate(_ bpm: Double) {
/// Apply an incoming live sample and (re)arm the shared staleness expiry the
/// three values arrive together, so they go stale together.
func applyLiveSample(bpm: Double, kcal: Double?, zone: Int?) {
heartRate = bpm
activeEnergyKcal = kcal
hrZone = zone
heartRateExpiry?.cancel()
heartRateExpiry = Task { [weak self] in
try? await Task.sleep(for: Self.hrStaleAfter)
guard !Task.isCancelled else { return }
self?.heartRate = nil
self?.activeEnergyKcal = nil
self?.hrZone = nil
}
}