Add per-routine target heart rate with live in-run indicator

Endurance routines (HIIT, cardio, cycling) can carry an optional target bpm
(RoutineDocument.targetHeartRate, not schema-bumped — same preference-field
rationale as restSeconds/autoAdvance), snapshotted onto the WorkoutDocument at
plan time like the other pacing fields.

During a run, the watch streams its live HR sample to the phone over a new
best-effort liveHeartRate message — deliberately outside the LiveProgress
machinery (no version bump, no staging/retry; a gauge, not a record), throttled
to changed-bpm-or-10s in the watch bridge. LiveRunState holds the sample with a
30s staleness auto-clear so a dead stream never shows a frozen number.

Both run screens show the reading only when the run carries a target: the
phone's ExerciseProgressView as a top pill, the watch's in the top-trailing
toolbar slot, each tinted by a shared ±5 bpm HeartRateBand with an arrow cue to
push harder (low) or ease off (high) — e.g. dialing in a treadmill incline to
hold a steady effort.

Claude-Session: https://claude.ai/code/session_01Y7ZhkCYWNiTSAFhFCGnJ8n
This commit is contained in:
2026-07-11 19:51:44 -04:00
parent b06a44eb40
commit 8854ad59d5
17 changed files with 283 additions and 10 deletions
+2
View File
@@ -1,5 +1,7 @@
**July 2026**
Cardio routines can now set a target heart rate — the run screen on watch and iPhone shows your live pulse with a cue to push harder or ease off.
The exercise screen now uses the full display in landscape by hiding the tab bar.
Tap any achievement to open a detail page explaining how to earn it and why it matters.
+5
View File
@@ -83,6 +83,11 @@ 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.
- **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.
- **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.
+6
View File
@@ -39,6 +39,9 @@ SwiftData cache entity (`Shared/Model/Entities.swift`) kept in sync by
| `updatedAt` | Date | | |
| `exercises` | [ExerciseDocument] | | Embedded aggregate members |
| `activityType` | Int | ✓ | Raw `WorkoutActivityType`; nil → `traditionalStrength`. *Not schema-bumped* |
| `restSeconds` | Int | ✓ | Per-routine rest length; nil → the Settings default applies. *Not schema-bumped* |
| `autoAdvance` | Bool | ✓ | Flow mode: finishing an exercise rests, then auto-advances into the next; nil → off. *Not schema-bumped* |
| `targetHeartRate` | Int | ✓ | Target bpm for endurance routines (`WorkoutActivityType.supportsHeartRateTarget`) — drives the live too-low/too-high indicator during a run; nil → no target. *Not schema-bumped* |
## ExerciseDocument (embedded in SplitDocument)
@@ -70,6 +73,9 @@ SwiftData cache entity (`Shared/Model/Entities.swift`) kept in sync by
| `logs` | [WorkoutLogDocument] | | Embedded aggregate members |
| `metrics` | WorkoutMetrics | ✓ | Nil until the workout completes and a writer fills it in |
| `deletedLogIDs` | [String: Date] | ✓ | Per-log deletion tombstones (`logID → when deleted`), phone-authored. Disambiguate an absent log during the per-log merge so a stale watch push can't resurrect a removed exercise; pruned after a 30-day grace. Added in schema v5 (`WorkoutMergePlanner`) |
| `restSeconds` | Int | ✓ | Plan-time snapshot of the routine's rest length (a running workout has no live link to its routine); nil → the Settings default. *Not schema-bumped* |
| `autoAdvance` | Bool | ✓ | Plan-time snapshot of the routine's flow-mode flag; nil → off. *Not schema-bumped* |
| `targetHeartRate` | Int | ✓ | Plan-time snapshot of the routine's target bpm; nil → no target. *Not schema-bumped* |
## WorkoutLogDocument (embedded in WorkoutDocument)
+20
View File
@@ -23,6 +23,7 @@ enum WCPayload {
static let requestSyncType = "requestSync" // watch phone (please push state)
static let liveProgressType = "liveProgress" // watch phone (ephemeral mirror frame)
static let liveEndedType = "liveEnded" // watch phone (stop mirroring a run)
static let liveHeartRateType = "liveHeartRate" // watch phone (ephemeral HR sample)
// MARK: - Phone Watch (application context: latest-state-wins)
@@ -138,4 +139,23 @@ enum WCPayload {
static func encodeLiveEnded(workoutID: String, logID: String) -> [String: Any] {
[typeKey: liveEndedType, lpWorkoutIDKey: workoutID, lpLogIDKey: logID]
}
// MARK: - Watch Phone (ephemeral live heart rate)
// Deliberately outside the `LiveProgress` machinery: HR is a device-level gauge
// (the wearer's pulse, not a run position), updates far more often than phase
// transitions, and must never bump the frame version sequence or re-anchor
// timers. Best-effort, latest-wins, display-only a dropped sample just means
// the readout refreshes on the next one.
static let hrBpmKey = "hrBpm"
static let hrAtKey = "hrAt"
static func encodeLiveHeartRate(bpm: Double, at date: Date) -> [String: Any] {
[typeKey: liveHeartRateType, hrBpmKey: bpm, hrAtKey: date]
}
static func decodeLiveHeartRate(_ dict: [String: Any]) -> (bpm: Double, at: Date)? {
guard let bpm = dict[hrBpmKey] as? Double, let at = dict[hrAtKey] as? Date else { return nil }
return (bpm, at)
}
}
+11 -4
View File
@@ -35,6 +35,11 @@ struct RoutineDocument: Codable, Sendable, Equatable, Identifiable {
/// which is preferable to quarantining the user's whole routine.
var restSeconds: Int? = nil
var autoAdvance: Bool? = nil
/// Target heart rate (bpm) for endurance routines drives the live too-low /
/// too-high indicator during a run. Nil no target. Only meaningful when the
/// `activityType` supports it (see `WorkoutActivityType.supportsHeartRateTarget`).
/// Optional and NOT schema-bumped, same rationale as the fields above.
var targetHeartRate: Int? = nil
// Bumped 12 when the weight-reminder fields (`weightLastUpdated`,
// `weightReminderWeeks`) and `category` were removed and `machineSettings` was
@@ -112,12 +117,13 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
/// period. See `WorkoutMergePlanner`.
var deletedLogIDs: [String: Date]? = nil
/// Snapshot of the routine's rest length / flow flag at plan time (a running
/// workout has no live link to its routine, same reason sets/reps/weight are
/// snapshotted per log). Optional and not schema-bumped, same rationale as
/// `RoutineDocument.restSeconds`/`autoAdvance`.
/// Snapshot of the routine's rest length / flow flag / target heart rate at plan
/// time (a running workout has no live link to its routine, same reason
/// sets/reps/weight are snapshotted per log). Optional and not schema-bumped,
/// same rationale as `RoutineDocument.restSeconds`/`autoAdvance`.
var restSeconds: Int? = nil
var autoAdvance: Bool? = nil
var targetHeartRate: Int? = nil
// PINNED CODING KEYS: the Swift properties `routineID`/`routineName` were renamed
// from `splitID`/`splitName`, but their on-disk (and iPhoneWatch wire) JSON keys
@@ -139,6 +145,7 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
case deletedLogIDs
case restSeconds
case autoAdvance
case targetHeartRate
}
// Bumped 12 when `metrics` was added: the captured HR/calorie data is
+12
View File
@@ -11,6 +11,16 @@ import SwiftData
// SwiftData PersistentIdentifier). Computed helpers preserve the API the views
// used against the old Core Data classes.
extension PersistentModel {
/// True while this model is registered with a live context. Reading a persisted
/// property on a dead model traps, and any entity can die under a view that
/// retains it (remote delete, reconcile prune the cache is rebuildable). Check
/// this before every such read: `isDeleted` alone misses the unregistered state
/// after the deletion saves, when `isDeleted` reads false again but reads still
/// trap (see `WorkoutDocument.init?(fromLive:)`).
var isLive: Bool { !isDeleted && modelContext != nil }
}
// MARK: - Routine
@Model
@@ -26,6 +36,7 @@ final class Routine {
var activityTypeRaw: Int = 0
var restSeconds: Int?
var autoAdvance: Bool?
var targetHeartRate: Int?
@Relationship(deleteRule: .cascade, inverse: \Exercise.routine)
var exercises: [Exercise] = []
@@ -152,6 +163,7 @@ final class Workout {
var deletedLogIDs: [String: Date]?
var restSeconds: Int?
var autoAdvance: Bool?
var targetHeartRate: Int?
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
var logs: [WorkoutLog] = []
+25
View File
@@ -78,6 +78,31 @@ enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
case .mindAndBody: "figure.mind.and.body"
}
}
/// Whether a routine of this type can carry a target heart rate. Steady-state
/// effort calibration ("raise the incline until you hit 140") only makes sense
/// for the endurance types a strength set's HR swings by design.
var supportsHeartRateTarget: Bool {
switch self {
case .hiit, .cardio, .cycling: true
default: false
}
}
}
/// Where a live heart-rate reading sits relative to a routine's target rate, with a
/// ±`tolerance` bpm dead band so the indicator doesn't flap around the boundary.
/// Shared by the phone and watch run screens so both classify identically.
enum HeartRateBand: Sendable {
case low, inRange, high
static let tolerance = 5
init(bpm: Double, target: Int) {
if bpm < Double(target - Self.tolerance) { self = .low }
else if bpm > Double(target + Self.tolerance) { self = .high }
else { self = .inRange }
}
}
/// Where a workout's health metrics came from: real watch sensors, or for records
+7 -3
View File
@@ -28,7 +28,8 @@ extension RoutineDocument {
createdAt: routine.createdAt, updatedAt: routine.updatedAt,
exercises: routine.exercisesArray.map(ExerciseDocument.init(from:)),
activityType: routine.activityTypeRaw,
restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance)
restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance,
targetHeartRate: routine.targetHeartRate)
}
}
@@ -52,7 +53,8 @@ extension WorkoutDocument {
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)),
metrics: workout.metrics,
deletedLogIDs: workout.deletedLogIDs,
restSeconds: workout.restSeconds, autoAdvance: workout.autoAdvance)
restSeconds: workout.restSeconds, autoAdvance: workout.autoAdvance,
targetHeartRate: workout.targetHeartRate)
}
/// Maps a *live* cache entity to a document, or `nil` when that entity has already
@@ -69,7 +71,7 @@ extension WorkoutDocument {
/// read still traps. A `@Model` retained across time (not freshly fetched) can reach
/// this map in that state, so check both.
init?(fromLive workout: Workout) {
guard !workout.isDeleted, workout.modelContext != nil else { return nil }
guard workout.isLive else { return nil }
self.init(from: workout)
}
@@ -140,6 +142,7 @@ enum CacheMapper {
routine.activityTypeRaw = doc.activityType ?? 0
routine.restSeconds = doc.restSeconds
routine.autoAdvance = doc.autoAdvance
routine.targetHeartRate = doc.targetHeartRate
let existing = Dictionary(routine.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
var keep = Set<String>()
@@ -200,6 +203,7 @@ enum CacheMapper {
workout.deletedLogIDs = doc.deletedLogIDs
workout.restSeconds = doc.restSeconds
workout.autoAdvance = doc.autoAdvance
workout.targetHeartRate = doc.targetHeartRate
let existing = Dictionary(workout.logs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
var keep = Set<String>()
@@ -217,6 +217,28 @@ final class WatchConnectivityBridge: NSObject {
}
}
// MARK: - Live heart rate (ephemeral; best-effort)
/// 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.
private var lastHRSentBpm: 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) {
guard let session, session.activationState == .activated, session.isReachable else { return }
let rounded = Int(bpm.rounded())
let now = Date()
if rounded == lastHRSentBpm, now.timeIntervalSince(lastHRSentAt) < 10 { return }
lastHRSentBpm = rounded
lastHRSentAt = now
session.sendMessage(WCPayload.encodeLiveHeartRate(bpm: bpm, at: now),
replyHandler: nil, errorHandler: nil)
}
/// Apply a live-run frame the phone sent. Catches our send counter up first (shared per-run
/// sequence), then arbitrates against our own staged outbound frame for the same run: if the
/// incoming frame outranks it, drop the staged one (so a later reconnect can't re-send stale
@@ -25,6 +25,12 @@ import WatchKit
struct ExerciseProgressView: View {
@Environment(\.dismiss) private var dismiss
/// Live HR straight off the session's builder drives the target-HR readout when
/// this run carries a `targetHeartRate`. Optional so hosts without a session
/// manager in the environment (the screenshot root) render without it.
@Environment(WorkoutSessionManager.self) private var sessionManager: WorkoutSessionManager?
@Environment(\.isLuminanceReduced) private var dimmed
/// The shared working workout document owned by the parent. We mutate the matching
/// log in place and ask the parent to forward each change through the bridge
/// driving the UI from this doc (not the cache) avoids losing rapid edits to the
@@ -285,6 +291,9 @@ struct ExerciseProgressView: View {
Image(systemName: "xmark")
}
}
ToolbarItem(placement: .topBarTrailing) {
heartRateReadout
}
}
.confirmationDialog("Cancel Exercise?", isPresented: $showingCancelConfirm, titleVisibility: .visible) {
Button("Cancel Exercise", role: .destructive) { dismiss() }
@@ -364,6 +373,26 @@ struct ExerciseProgressView: View {
}
}
// MARK: - Live heart rate vs. target
/// Compact live HR against this run's target (cardio routines only): bpm tinted by
/// band with a cue arrow ease off (down) or push harder (up). Renders nothing
/// unless the run carries a target and a sample exists; dims under Always-On like
/// the timers.
@ViewBuilder
private var heartRateReadout: some View {
if let target = doc.targetHeartRate, let bpm = sessionManager?.currentHeartRate {
let band = HeartRateBand(bpm: bpm, target: target)
HStack(spacing: 2) {
Text("\(Int(bpm.rounded()))")
.monospacedDigit()
Image(systemName: band.cueSymbol)
}
.font(.caption2.weight(.semibold))
.foregroundStyle(dimmed ? Color.secondary : band.tint)
}
}
// MARK: - Live mirror
/// Finish the initial-page restore, then either follow an in-progress remote driver or, if
@@ -749,6 +778,27 @@ private extension Color {
static let restTimer = Color(white: 0.74)
}
// MARK: - Heart-rate band presentation
private extension HeartRateBand {
/// The correction cue: below target push harder (up), above ease off (down).
var cueSymbol: String {
switch self {
case .low: "arrow.up"
case .inRange: "checkmark"
case .high: "arrow.down"
}
}
var tint: Color {
switch self {
case .low: .blue
case .inRange: .green
case .high: .orange
}
}
}
// MARK: - Phase Timer Layout
/// The live timer a phase shows, described by its wall-clock anchors so `PhaseTimerLayout` can
@@ -36,6 +36,9 @@ 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) }
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,6 +38,11 @@ final class WorkoutSessionManager: NSObject {
private(set) var currentHeartRate: Double?
private(set) var currentActiveEnergyKcal: Double?
/// 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)?
/// Seconds accumulated in each of 5 heart-rate zones (lowhigh). Built on the
/// watch because it's the only place with the full HR stream. Empty/ignored when
/// the user's age (hence max HR) is unknown.
@@ -182,7 +187,10 @@ 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 }
if let newHR {
currentHeartRate = newHR
onHeartRateSample?(newHR)
}
lastHRSampleDate = now
currentActiveEnergyKcal = builder?.statistics(for: HKQuantityType(.activeEnergyBurned))?
+20
View File
@@ -34,6 +34,26 @@ final class LiveRunState {
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?
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) {
heartRate = bpm
heartRateExpiry?.cancel()
heartRateExpiry = Task { [weak self] in
try? await Task.sleep(for: Self.hrStaleAfter)
guard !Task.isCancelled else { return }
self?.heartRate = 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 }
@@ -267,6 +267,10 @@ final class PhoneConnectivityBridge: NSObject {
if let logID = dict[WCPayload.lpLogIDKey] as? String {
Task { @MainActor in self.liveRunState.end(logID: logID) }
}
case WCPayload.liveHeartRateType:
if let sample = WCPayload.decodeLiveHeartRate(dict) {
Task { @MainActor in self.liveRunState.applyHeartRate(sample.bpm) }
}
default:
break
}
+2 -1
View File
@@ -36,7 +36,8 @@ enum WorkoutStarter {
createdAt: startDate,
updatedAt: startDate,
logs: logs,
restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance
restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance,
targetHeartRate: routine.targetHeartRate
)
await sync.save(workout: doc)
@@ -32,6 +32,8 @@ struct RoutineAddEditView: View {
@State private var autoAdvance: Bool = false
@State private var restOverrideEnabled: Bool = false
@State private var restSecondsValue: Int = 45
@State private var targetHREnabled: Bool = false
@State private var targetHRValue: Int = 135
@State private var showingIconPicker: Bool = false
@State private var showingDeleteConfirmation: Bool = false
@@ -48,6 +50,8 @@ struct RoutineAddEditView: View {
_autoAdvance = State(initialValue: routine.autoAdvance ?? false)
_restOverrideEnabled = State(initialValue: routine.restSeconds != nil)
_restSecondsValue = State(initialValue: routine.restSeconds ?? 45)
_targetHREnabled = State(initialValue: routine.targetHeartRate != nil)
_targetHRValue = State(initialValue: routine.targetHeartRate ?? 135)
}
}
@@ -136,6 +140,26 @@ struct RoutineAddEditView: View {
Text("Auto-Advance flows from one exercise to the next with a rest between, so you can run the whole routine hands-free. Custom Rest Time sets this routine's rest — used between sets, and between exercises when auto-advancing; otherwise the Settings default applies.")
}
// Only endurance types get a target a strength set's HR swings by design.
if activityType.supportsHeartRateTarget {
Section {
Toggle("Target Heart Rate", isOn: $targetHREnabled)
if targetHREnabled {
Stepper(value: $targetHRValue, in: 80...200, step: 5) {
HStack {
Text("Target")
Spacer()
Text("\(targetHRValue) bpm").foregroundColor(.secondary)
}
}
}
} header: {
Text("Heart Rate")
} footer: {
Text("During a run, the exercise screen shows your live heart rate from the watch with a cue to ease off or push harder — useful for holding a steady effort, like dialing in a treadmill incline.")
}
}
if let routine = routine {
Section(header: Text("Exercises")) {
NavigationLink {
@@ -205,6 +229,9 @@ struct RoutineAddEditView: View {
doc.activityType = activityType.rawValue
doc.restSeconds = restOverrideEnabled ? restSecondsValue : nil
doc.autoAdvance = autoAdvance ? true : nil
// Cleared when the type no longer supports a target (the section hides).
doc.targetHeartRate = activityType.supportsHeartRateTarget && targetHREnabled
? targetHRValue : nil
doc.updatedAt = Date()
Task { await sync.save(routine: doc) }
} else {
@@ -222,7 +249,9 @@ struct RoutineAddEditView: View {
exercises: [],
activityType: activityType.rawValue,
restSeconds: restOverrideEnabled ? restSecondsValue : nil,
autoAdvance: autoAdvance ? true : nil
autoAdvance: autoAdvance ? true : nil,
targetHeartRate: activityType.supportsHeartRateTarget && targetHREnabled
? targetHRValue : nil
)
Task { await sync.save(routine: doc) }
}
@@ -30,6 +30,10 @@ struct ExerciseProgressView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
/// Live watch-forwarded heart rate (see `LiveRunState.heartRate`) drives the
/// target-HR pill when this run carries a `targetHeartRate`.
@Environment(LiveRunState.self) private var liveRun
/// The shared working workout document owned by the parent list. We mutate the
/// matching log in place and ask the parent to persist each change driving the UI
@@ -391,6 +395,9 @@ struct ExerciseProgressView: View {
.padding(.bottom, 8)
}
}
.overlay(alignment: .top) {
heartRatePill
}
// 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
@@ -731,6 +738,33 @@ struct ExerciseProgressView: View {
}
}
// MARK: - Live heart rate vs. target
/// 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.
@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)
}
.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)
}
}
// MARK: - Adjust the last set's entry
/// The just-completed set's recorded entry, shown on the rest and finish pages
@@ -1119,6 +1153,27 @@ private enum WorkoutHaptic {
}
}
// MARK: - Heart-rate band presentation
private extension HeartRateBand {
/// The correction cue: below target push harder (up), above ease off (down).
var cueSymbol: String {
switch self {
case .low: "arrow.up"
case .inRange: "checkmark"
case .high: "arrow.down"
}
}
var tint: Color {
switch self {
case .low: .blue
case .inRange: .green
case .high: .orange
}
}
}
// MARK: - Phase Colors
private extension Color {