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:
@@ -221,22 +221,28 @@ final class WatchConnectivityBridge: NSObject {
|
||||
|
||||
/// Rounded bpm last sent and when, to throttle the forward: HR samples land every few
|
||||
/// seconds, but an unchanged reading only needs an occasional keep-alive so the phone's
|
||||
/// staleness window doesn't blank the readout mid-run.
|
||||
/// staleness window doesn't blank the readout mid-run. A zone flip breaks the throttle
|
||||
/// so the panel's zone can't lag a boundary crossing by the keep-alive interval.
|
||||
private var lastHRSentBpm: Int?
|
||||
private var lastHRSentZone: Int?
|
||||
private var lastHRSentAt = Date.distantPast
|
||||
|
||||
/// Forward a live heart-rate sample to a mirroring phone. Unlike the run frames this is
|
||||
/// pure best-effort — reachable-only, no staging, no retry: a gauge, not a record. The
|
||||
/// durable HR summary still lands in `WorkoutMetrics` when the session finishes.
|
||||
func sendLiveHeartRate(_ bpm: Double) {
|
||||
/// Forward a live sample (heart rate + running calories + zone) to a mirroring phone.
|
||||
/// Unlike the run frames this is pure best-effort — reachable-only, no staging, no
|
||||
/// retry: a gauge, not a record. The durable summary still lands in `WorkoutMetrics`
|
||||
/// when the session finishes.
|
||||
func sendLiveSample(_ sample: WorkoutSessionManager.LiveSample) {
|
||||
guard let session, session.activationState == .activated, session.isReachable else { return }
|
||||
let rounded = Int(bpm.rounded())
|
||||
let rounded = Int(sample.bpm.rounded())
|
||||
let now = Date()
|
||||
if rounded == lastHRSentBpm, now.timeIntervalSince(lastHRSentAt) < 10 { return }
|
||||
if rounded == lastHRSentBpm, sample.zone == lastHRSentZone,
|
||||
now.timeIntervalSince(lastHRSentAt) < 10 { return }
|
||||
lastHRSentBpm = rounded
|
||||
lastHRSentZone = sample.zone
|
||||
lastHRSentAt = now
|
||||
session.sendMessage(WCPayload.encodeLiveHeartRate(bpm: bpm, at: now),
|
||||
replyHandler: nil, errorHandler: nil)
|
||||
session.sendMessage(
|
||||
WCPayload.encodeLiveHeartRate(bpm: sample.bpm, at: now, kcal: sample.kcal, zone: sample.zone),
|
||||
replyHandler: nil, errorHandler: nil)
|
||||
}
|
||||
|
||||
/// Apply a live-run frame the phone sent. Catches our send counter up first (shared per-run
|
||||
|
||||
@@ -36,9 +36,10 @@ final class WatchAppServices {
|
||||
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 HR sample to a mirroring phone (best-effort, display-only).
|
||||
// No cycle: the bridge never references the session manager back.
|
||||
sessionManager.onHeartRateSample = { [bridge] bpm in bridge.sendLiveHeartRate(bpm) }
|
||||
// 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
|
||||
|
||||
@@ -38,10 +38,19 @@ final class WorkoutSessionManager: NSObject {
|
||||
private(set) var currentHeartRate: Double?
|
||||
private(set) var currentActiveEnergyKcal: Double?
|
||||
|
||||
/// One live reading for the phone's status panel: the fresh heart rate plus the
|
||||
/// session's running calorie total and the HR zone (1–5) that rate falls in.
|
||||
/// `kcal`/`zone` are nil while unknown (no energy sample yet / max HR unknown).
|
||||
struct LiveSample {
|
||||
let bpm: Double
|
||||
let kcal: Double?
|
||||
let zone: Int?
|
||||
}
|
||||
|
||||
/// Fired with each fresh heart-rate sample while the session runs. The bridge hangs
|
||||
/// off this to forward the reading to the phone (best-effort, display-only) — a
|
||||
/// callback rather than observation so the sensor stream has exactly one push path.
|
||||
var onHeartRateSample: ((Double) -> Void)?
|
||||
var onHeartRateSample: ((LiveSample) -> Void)?
|
||||
|
||||
/// Seconds accumulated in each of 5 heart-rate zones (low→high). Built on the
|
||||
/// watch because it's the only place with the full HR stream. Empty/ignored when
|
||||
@@ -187,14 +196,17 @@ final class WorkoutSessionManager: NSObject {
|
||||
let dt = now.timeIntervalSince(last)
|
||||
if dt > 0, dt < 60 { hrZoneSeconds[Self.zoneIndex(for: prevHR, maxHR: maxHeartRate)] += dt }
|
||||
}
|
||||
if let newHR {
|
||||
currentHeartRate = newHR
|
||||
onHeartRateSample?(newHR)
|
||||
}
|
||||
lastHRSampleDate = now
|
||||
|
||||
// Refresh the calorie total before forwarding, so the sample carries it.
|
||||
currentActiveEnergyKcal = builder?.statistics(for: HKQuantityType(.activeEnergyBurned))?
|
||||
.sumQuantity()?.doubleValue(for: .kilocalorie())
|
||||
|
||||
if let newHR {
|
||||
currentHeartRate = newHR
|
||||
let zone = maxHeartRate.map { Self.zoneIndex(for: newHR, maxHR: $0) + 1 }
|
||||
onHeartRateSample?(LiveSample(bpm: newHR, kcal: currentActiveEnergyKcal, zone: zone))
|
||||
}
|
||||
lastHRSampleDate = now
|
||||
}
|
||||
|
||||
/// Bucket an instantaneous heart rate into one of five zones (0–4) by its fraction of the
|
||||
|
||||
Reference in New Issue
Block a user