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:
@@ -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