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.
87 lines
3.5 KiB
Swift
87 lines
3.5 KiB
Swift
//
|
||
// LiveRunState.swift
|
||
// Workouts
|
||
//
|
||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||
//
|
||
|
||
import Foundation
|
||
import Observation
|
||
|
||
/// Phone-side holder for the *ephemeral* live-run frames the watch broadcasts while it drives
|
||
/// an exercise (see `LiveProgress`). The mirror UI observes this; nothing here is persisted.
|
||
///
|
||
/// Phase 1 is watch-drives / phone-mirrors, so this is read-only state fed by the connectivity
|
||
/// bridge — the phone never sends back, which is why there's no echo loop to guard against yet.
|
||
@Observable
|
||
@MainActor
|
||
final class LiveRunState {
|
||
/// The latest frame from the driving device, or `nil` when no run is being mirrored.
|
||
private(set) var current: LiveProgress?
|
||
|
||
/// A log the user manually closed the mirror for; suppressed until that run ends.
|
||
private var mutedLogID: String?
|
||
|
||
/// The run currently open in a non-cover driver on *this* device (its in-list/navigated
|
||
/// `ExerciseProgressView`). When an incoming frame is for that run, the device follows it
|
||
/// inline in that driver, so the mirror cover doesn't stack on top of a run already open.
|
||
var navigatedRunID: String?
|
||
|
||
/// The frame to actually present as a cover: the latest frame, unless the user dismissed it
|
||
/// or already has that run open inline.
|
||
var presentable: LiveProgress? {
|
||
guard let c = current, c.logID != mutedLogID, c.logID != navigatedRunID else { return nil }
|
||
return c
|
||
}
|
||
|
||
/// Latest live heart-rate sample forwarded by the watch (bpm), or `nil` when none is
|
||
/// fresh. Device-level, not run-scoped — it's the wearer's pulse. Auto-clears after
|
||
/// `hrStaleAfter` without a new sample, so the UI never shows a frozen number after
|
||
/// 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 (1–5) 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 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
|
||
}
|
||
}
|
||
|
||
/// Apply an incoming frame, dropping a stale (or redelivered) one for the same run.
|
||
func apply(_ frame: LiveProgress) {
|
||
if let c = current, c.logID == frame.logID, !frame.isNewer(than: c) { return }
|
||
current = frame
|
||
}
|
||
|
||
/// The driver left the run (cancel / done / navigated away) — stop mirroring it.
|
||
func end(logID: String) {
|
||
if current?.logID == logID { current = nil }
|
||
if mutedLogID == logID { mutedLogID = nil }
|
||
}
|
||
|
||
/// The user dismissed the mirror; don't re-present this run until it ends.
|
||
func mute() {
|
||
mutedLogID = current?.logID
|
||
}
|
||
}
|