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:
@@ -1,5 +1,7 @@
|
||||
**July 2026**
|
||||
|
||||
A live status panel between the timer and the figure now shows heart rate (with the target color cues, in much larger type), HR zone, calories, and workout time.
|
||||
|
||||
A new Library tab puts your routines and the exercise library one tap away instead of buried in Settings.
|
||||
|
||||
Drag routines into your own order — the Today board and routine pickers follow it everywhere.
|
||||
|
||||
@@ -93,11 +93,14 @@ your own iCloud Drive.
|
||||
bidirectional: drive from either device — swipe ahead, finish a set, add one — and the
|
||||
other follows. Only *human* transitions are sent; automatic ones (a rest timer ending)
|
||||
advance both devices independently off shared start times, so they never fight.
|
||||
- **Live status panel** — between the run screen's timer and figure (a band in
|
||||
portrait, a column in landscape) sits a big-digit readout of watch-streamed heart
|
||||
rate, HR zone (1–5), active calories, and the workout stopwatch, glanceable from
|
||||
across the room.
|
||||
- **Target heart rate** — give a cardio routine (HIIT, cardio, cycling) a target bpm
|
||||
and the run screen shows your live heart rate from the watch — on the watch itself
|
||||
and streamed to a propped-up iPhone — tinted green in range, with an arrow cue to
|
||||
push harder or ease off. Perfect for dialing in a treadmill incline and holding a
|
||||
steady effort.
|
||||
and the live heart-rate readout — on the watch and in the iPhone's status panel —
|
||||
tints green in range, with an arrow cue to push harder or ease off. Perfect for
|
||||
dialing in a treadmill incline and holding a steady effort.
|
||||
- **Watch face complication** — a launcher complication you can place on any Apple
|
||||
Watch face; tap it to open the app. Available in the circular, corner, inline, and
|
||||
rectangular accessory slots.
|
||||
|
||||
@@ -149,13 +149,21 @@ enum WCPayload {
|
||||
// the readout refreshes on the next one.
|
||||
static let hrBpmKey = "hrBpm"
|
||||
static let hrAtKey = "hrAt"
|
||||
static let hrKcalKey = "hrKcal"
|
||||
static let hrZoneKey = "hrZone"
|
||||
|
||||
static func encodeLiveHeartRate(bpm: Double, at date: Date) -> [String: Any] {
|
||||
[typeKey: liveHeartRateType, hrBpmKey: bpm, hrAtKey: date]
|
||||
/// The session's running calories and HR zone ride along with each sample when
|
||||
/// known — absent keys (an older watch, or no energy sample yet) decode as nil,
|
||||
/// so the two builds stay wire-compatible in both directions.
|
||||
static func encodeLiveHeartRate(bpm: Double, at date: Date, kcal: Double? = nil, zone: Int? = nil) -> [String: Any] {
|
||||
var dict: [String: Any] = [typeKey: liveHeartRateType, hrBpmKey: bpm, hrAtKey: date]
|
||||
if let kcal { dict[hrKcalKey] = kcal }
|
||||
if let zone { dict[hrZoneKey] = zone }
|
||||
return dict
|
||||
}
|
||||
|
||||
static func decodeLiveHeartRate(_ dict: [String: Any]) -> (bpm: Double, at: Date)? {
|
||||
static func decodeLiveHeartRate(_ dict: [String: Any]) -> (bpm: Double, at: Date, kcal: Double?, zone: Int?)? {
|
||||
guard let bpm = dict[hrBpmKey] as? Double, let at = dict[hrAtKey] as? Date else { return nil }
|
||||
return (bpm, at)
|
||||
return (bpm, at, dict[hrKcalKey] as? Double, dict[hrZoneKey] as? Int)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (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 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -269,7 +269,9 @@ final class PhoneConnectivityBridge: NSObject {
|
||||
}
|
||||
case WCPayload.liveHeartRateType:
|
||||
if let sample = WCPayload.decodeLiveHeartRate(dict) {
|
||||
Task { @MainActor in self.liveRunState.applyHeartRate(sample.bpm) }
|
||||
Task { @MainActor in
|
||||
self.liveRunState.applyLiveSample(bpm: sample.bpm, kcal: sample.kcal, zone: sample.zone)
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
|
||||
@@ -43,6 +43,8 @@ struct ScreenshotRootView: View {
|
||||
NavigationStack { ExerciseView(workout: workout, logID: logID ?? "") }
|
||||
case "run":
|
||||
ScreenshotRunView(workout: workout)
|
||||
// Believable watch-fed values so the status panel isn't placeholders.
|
||||
.onAppear { services.liveRunState.applyLiveSample(bpm: 128, kcal: 236, zone: 3) }
|
||||
case "settings":
|
||||
SettingsView()
|
||||
default:
|
||||
|
||||
@@ -24,16 +24,18 @@ import UIKit
|
||||
///
|
||||
/// The paged flow occupies the **top half** of the screen; the bottom half shows the
|
||||
/// animated form-guide figure when the exercise has a bundled motion
|
||||
/// (`ExerciseFigureSlot`). A row of phase dots tracks progress: purple for work,
|
||||
/// teal for rest, with the current phase drawn as a wider dash.
|
||||
/// (`ExerciseFigureSlot`). Between the two halves sits the **live status panel** —
|
||||
/// heart rate (tinted by the target band on cardio runs), HR zone, calories, and the
|
||||
/// workout stopwatch in big glanceable digits. A row of phase dots tracks progress:
|
||||
/// purple for work, teal for rest, with the current phase drawn as a wider dash.
|
||||
struct ExerciseProgressView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
|
||||
/// Live watch-forwarded heart rate (see `LiveRunState.heartRate`) — drives the
|
||||
/// target-HR pill when this run carries a `targetHeartRate`.
|
||||
/// Live watch-forwarded metrics (heart rate, calories, zone — see `LiveRunState`)
|
||||
/// — they drive the status panel between the two halves.
|
||||
@Environment(LiveRunState.self) private var liveRun
|
||||
|
||||
|
||||
@@ -468,9 +470,9 @@ struct ExerciseProgressView: View {
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
heartRatePill
|
||||
}
|
||||
|
||||
// The live status panel sits between the halves in both orientations.
|
||||
statusPanel
|
||||
|
||||
// Bottom half: the looping form-guide figure. Shows the *next* exercise while
|
||||
// resting between exercises (a live preview of what's coming up), otherwise this
|
||||
@@ -851,30 +853,89 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live heart rate vs. target
|
||||
// MARK: - Live status panel
|
||||
|
||||
/// Live heart rate against this run's target (cardio routines only): the bpm the
|
||||
/// watch is streaming, tinted by band, with a cue to ease off (arrow down) or push
|
||||
/// harder (arrow up) — how you dial in a treadmill incline from a propped phone.
|
||||
/// Renders nothing unless the run carries a target *and* a fresh sample exists, so
|
||||
/// it has zero footprint on strength runs or when the watch isn't active.
|
||||
/// Scales the panel's metric digits with Dynamic Type.
|
||||
@ScaledMetric(relativeTo: .title) private var metricFontSize: CGFloat = 34
|
||||
|
||||
/// When the workout actually began: the earliest started exercise, else the stamp
|
||||
/// the workout was created with (a run still sitting on its first Ready page).
|
||||
private var workoutStartAnchor: Date {
|
||||
doc.logs.compactMap(\.startedAt).min() ?? doc.start
|
||||
}
|
||||
|
||||
/// The status strip between the two halves: live heart rate (tinted by the target
|
||||
/// band, with the ease-off/push-harder cue, when the run carries a target), HR
|
||||
/// zone, and active calories — all watch-fed, dimmed to a placeholder until
|
||||
/// samples flow — plus the always-ticking workout stopwatch. A horizontal band in
|
||||
/// portrait, a vertical column in landscape: the *inverse* of `splitLayout`, so it
|
||||
/// always sits between the halves.
|
||||
@ViewBuilder
|
||||
private var heartRatePill: some View {
|
||||
if let target = doc.targetHeartRate, let bpm = liveRun.heartRate {
|
||||
let band = HeartRateBand(bpm: bpm, target: target)
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "heart.fill")
|
||||
Text("\(Int(bpm.rounded()))")
|
||||
.monospacedDigit()
|
||||
Image(systemName: band.cueSymbol)
|
||||
private var statusPanel: some View {
|
||||
Group {
|
||||
if verticalSizeClass == .compact {
|
||||
VStack(spacing: 22) { statusMetrics(expand: false) }
|
||||
.padding(.horizontal, 10)
|
||||
.frame(maxHeight: .infinity)
|
||||
} else {
|
||||
HStack(spacing: 0) { statusMetrics(expand: true) }
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(band.tint)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.background(.quaternary.opacity(0.5), in: Capsule())
|
||||
.padding(.top, 6)
|
||||
.animation(.easeInOut(duration: 0.3), value: band)
|
||||
}
|
||||
.animation(.snappy(duration: 0.25), value: liveRun.heartRate)
|
||||
.animation(.snappy(duration: 0.25), value: liveRun.activeEnergyKcal)
|
||||
.animation(.snappy(duration: 0.25), value: liveRun.hrZone)
|
||||
}
|
||||
|
||||
/// The four metric cells, in fixed order. `expand` spreads them evenly across the
|
||||
/// portrait band; the landscape column keeps intrinsic widths.
|
||||
@ViewBuilder
|
||||
private func statusMetrics(expand: Bool) -> some View {
|
||||
Group {
|
||||
heartRateMetric
|
||||
metricCell(liveRun.hrZone.map { Text(String($0)) }, caption: "ZONE")
|
||||
metricCell(liveRun.activeEnergyKcal.map { Text("\(Int($0.rounded()))") }, caption: "KCAL")
|
||||
metricCell(Text(workoutStartAnchor, style: .timer), caption: "ELAPSED")
|
||||
}
|
||||
.frame(maxWidth: expand ? .infinity : nil)
|
||||
}
|
||||
|
||||
/// Heart rate, tinted by band with the correction cue — ease off (arrow down) or
|
||||
/// push harder (arrow up) — when the run carries a target; neutral otherwise, so
|
||||
/// the readout is still useful on a strength run with a watch session going.
|
||||
private var heartRateMetric: some View {
|
||||
let band = doc.targetHeartRate.flatMap { target in
|
||||
liveRun.heartRate.map { HeartRateBand(bpm: $0, target: target) }
|
||||
}
|
||||
return metricCell(
|
||||
liveRun.heartRate.map { Text("\(Int($0.rounded()))") },
|
||||
caption: "BPM",
|
||||
cue: band?.cueSymbol,
|
||||
tint: band?.tint ?? .primary)
|
||||
}
|
||||
|
||||
/// One panel cell: big rounded digits over a small caption. A nil value renders as
|
||||
/// a dimmed placeholder so the layout never jumps as watch samples start flowing.
|
||||
private func metricCell(_ value: Text?, caption: String, cue: String? = nil, tint: Color = .primary) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: 5) {
|
||||
(value ?? Text(verbatim: "–"))
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText())
|
||||
if let cue {
|
||||
Image(systemName: cue)
|
||||
.font(.system(size: metricFontSize * 0.5, weight: .bold))
|
||||
}
|
||||
}
|
||||
.font(.system(size: metricFontSize, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(value == nil ? AnyShapeStyle(.quaternary) : AnyShapeStyle(tint))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
|
||||
Text(caption)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user