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** **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. 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. 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 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) 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. 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 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 Watch face; tap it to open the app. Available in the circular, corner, inline, and
rectangular accessory slots. rectangular accessory slots.
+6
View File
@@ -39,6 +39,9 @@ SwiftData cache entity (`Shared/Model/Entities.swift`) kept in sync by
| `updatedAt` | Date | | | | `updatedAt` | Date | | |
| `exercises` | [ExerciseDocument] | | Embedded aggregate members | | `exercises` | [ExerciseDocument] | | Embedded aggregate members |
| `activityType` | Int | ✓ | Raw `WorkoutActivityType`; nil → `traditionalStrength`. *Not schema-bumped* | | `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) ## ExerciseDocument (embedded in SplitDocument)
@@ -70,6 +73,9 @@ SwiftData cache entity (`Shared/Model/Entities.swift`) kept in sync by
| `logs` | [WorkoutLogDocument] | | Embedded aggregate members | | `logs` | [WorkoutLogDocument] | | Embedded aggregate members |
| `metrics` | WorkoutMetrics | ✓ | Nil until the workout completes and a writer fills it in | | `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`) | | `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) ## WorkoutLogDocument (embedded in WorkoutDocument)
+20
View File
@@ -23,6 +23,7 @@ enum WCPayload {
static let requestSyncType = "requestSync" // watch phone (please push state) static let requestSyncType = "requestSync" // watch phone (please push state)
static let liveProgressType = "liveProgress" // watch phone (ephemeral mirror frame) static let liveProgressType = "liveProgress" // watch phone (ephemeral mirror frame)
static let liveEndedType = "liveEnded" // watch phone (stop mirroring a run) 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) // MARK: - Phone Watch (application context: latest-state-wins)
@@ -138,4 +139,23 @@ enum WCPayload {
static func encodeLiveEnded(workoutID: String, logID: String) -> [String: Any] { static func encodeLiveEnded(workoutID: String, logID: String) -> [String: Any] {
[typeKey: liveEndedType, lpWorkoutIDKey: workoutID, lpLogIDKey: logID] [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. /// which is preferable to quarantining the user's whole routine.
var restSeconds: Int? = nil var restSeconds: Int? = nil
var autoAdvance: Bool? = 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`, // Bumped 12 when the weight-reminder fields (`weightLastUpdated`,
// `weightReminderWeeks`) and `category` were removed and `machineSettings` was // `weightReminderWeeks`) and `category` were removed and `machineSettings` was
@@ -112,12 +117,13 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
/// period. See `WorkoutMergePlanner`. /// period. See `WorkoutMergePlanner`.
var deletedLogIDs: [String: Date]? = nil var deletedLogIDs: [String: Date]? = nil
/// Snapshot of the routine's rest length / flow flag at plan time (a running /// Snapshot of the routine's rest length / flow flag / target heart rate at plan
/// workout has no live link to its routine, same reason sets/reps/weight are /// time (a running workout has no live link to its routine, same reason
/// snapshotted per log). Optional and not schema-bumped, same rationale as /// sets/reps/weight are snapshotted per log). Optional and not schema-bumped,
/// `RoutineDocument.restSeconds`/`autoAdvance`. /// same rationale as `RoutineDocument.restSeconds`/`autoAdvance`.
var restSeconds: Int? = nil var restSeconds: Int? = nil
var autoAdvance: Bool? = nil var autoAdvance: Bool? = nil
var targetHeartRate: Int? = nil
// PINNED CODING KEYS: the Swift properties `routineID`/`routineName` were renamed // PINNED CODING KEYS: the Swift properties `routineID`/`routineName` were renamed
// from `splitID`/`splitName`, but their on-disk (and iPhoneWatch wire) JSON keys // 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 deletedLogIDs
case restSeconds case restSeconds
case autoAdvance case autoAdvance
case targetHeartRate
} }
// Bumped 12 when `metrics` was added: the captured HR/calorie data is // 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 // SwiftData PersistentIdentifier). Computed helpers preserve the API the views
// used against the old Core Data classes. // 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 // MARK: - Routine
@Model @Model
@@ -26,6 +36,7 @@ final class Routine {
var activityTypeRaw: Int = 0 var activityTypeRaw: Int = 0
var restSeconds: Int? var restSeconds: Int?
var autoAdvance: Bool? var autoAdvance: Bool?
var targetHeartRate: Int?
@Relationship(deleteRule: .cascade, inverse: \Exercise.routine) @Relationship(deleteRule: .cascade, inverse: \Exercise.routine)
var exercises: [Exercise] = [] var exercises: [Exercise] = []
@@ -152,6 +163,7 @@ final class Workout {
var deletedLogIDs: [String: Date]? var deletedLogIDs: [String: Date]?
var restSeconds: Int? var restSeconds: Int?
var autoAdvance: Bool? var autoAdvance: Bool?
var targetHeartRate: Int?
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout) @Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
var logs: [WorkoutLog] = [] var logs: [WorkoutLog] = []
+25
View File
@@ -78,6 +78,31 @@ enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
case .mindAndBody: "figure.mind.and.body" 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 /// 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, createdAt: routine.createdAt, updatedAt: routine.updatedAt,
exercises: routine.exercisesArray.map(ExerciseDocument.init(from:)), exercises: routine.exercisesArray.map(ExerciseDocument.init(from:)),
activityType: routine.activityTypeRaw, 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:)), logs: workout.logsArray.map(WorkoutLogDocument.init(from:)),
metrics: workout.metrics, metrics: workout.metrics,
deletedLogIDs: workout.deletedLogIDs, 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 /// 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 /// read still traps. A `@Model` retained across time (not freshly fetched) can reach
/// this map in that state, so check both. /// this map in that state, so check both.
init?(fromLive workout: Workout) { init?(fromLive workout: Workout) {
guard !workout.isDeleted, workout.modelContext != nil else { return nil } guard workout.isLive else { return nil }
self.init(from: workout) self.init(from: workout)
} }
@@ -140,6 +142,7 @@ enum CacheMapper {
routine.activityTypeRaw = doc.activityType ?? 0 routine.activityTypeRaw = doc.activityType ?? 0
routine.restSeconds = doc.restSeconds routine.restSeconds = doc.restSeconds
routine.autoAdvance = doc.autoAdvance routine.autoAdvance = doc.autoAdvance
routine.targetHeartRate = doc.targetHeartRate
let existing = Dictionary(routine.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) let existing = Dictionary(routine.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
var keep = Set<String>() var keep = Set<String>()
@@ -200,6 +203,7 @@ enum CacheMapper {
workout.deletedLogIDs = doc.deletedLogIDs workout.deletedLogIDs = doc.deletedLogIDs
workout.restSeconds = doc.restSeconds workout.restSeconds = doc.restSeconds
workout.autoAdvance = doc.autoAdvance workout.autoAdvance = doc.autoAdvance
workout.targetHeartRate = doc.targetHeartRate
let existing = Dictionary(workout.logs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) let existing = Dictionary(workout.logs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
var keep = Set<String>() 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 /// 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 /// 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 /// 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 { struct ExerciseProgressView: View {
@Environment(\.dismiss) private var dismiss @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 /// 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 /// 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 /// 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") Image(systemName: "xmark")
} }
} }
ToolbarItem(placement: .topBarTrailing) {
heartRateReadout
}
} }
.confirmationDialog("Cancel Exercise?", isPresented: $showingCancelConfirm, titleVisibility: .visible) { .confirmationDialog("Cancel Exercise?", isPresented: $showingCancelConfirm, titleVisibility: .visible) {
Button("Cancel Exercise", role: .destructive) { dismiss() } 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 // MARK: - Live mirror
/// Finish the initial-page restore, then either follow an in-progress remote driver or, if /// 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) 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 // MARK: - Phase Timer Layout
/// The live timer a phase shows, described by its wall-clock anchors so `PhaseTimerLayout` can /// 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 self.sessionCoordinator = coordinator
// weak break: the coordinator is held here; the bridge only borrows it. // weak break: the coordinator is held here; the bridge only borrows it.
bridge.onWorkoutsChanged = { [weak coordinator] in coordinator?.reconcile() } 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() bridge.activate()
// Seed `previouslyActiveIDs` from whatever the bridge already applied at launch, so the // 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 // 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 currentHeartRate: Double?
private(set) var currentActiveEnergyKcal: 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 /// 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 /// watch because it's the only place with the full HR stream. Empty/ignored when
/// the user's age (hence max HR) is unknown. /// the user's age (hence max HR) is unknown.
@@ -182,7 +187,10 @@ final class WorkoutSessionManager: NSObject {
let dt = now.timeIntervalSince(last) let dt = now.timeIntervalSince(last)
if dt > 0, dt < 60 { hrZoneSeconds[Self.zoneIndex(for: prevHR, maxHR: maxHeartRate)] += dt } 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 lastHRSampleDate = now
currentActiveEnergyKcal = builder?.statistics(for: HKQuantityType(.activeEnergyBurned))? currentActiveEnergyKcal = builder?.statistics(for: HKQuantityType(.activeEnergyBurned))?
+20
View File
@@ -34,6 +34,26 @@ final class LiveRunState {
return c 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. /// Apply an incoming frame, dropping a stale (or redelivered) one for the same run.
func apply(_ frame: LiveProgress) { func apply(_ frame: LiveProgress) {
if let c = current, c.logID == frame.logID, !frame.isNewer(than: c) { return } 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 { if let logID = dict[WCPayload.lpLogIDKey] as? String {
Task { @MainActor in self.liveRunState.end(logID: logID) } 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: default:
break break
} }
+2 -1
View File
@@ -36,7 +36,8 @@ enum WorkoutStarter {
createdAt: startDate, createdAt: startDate,
updatedAt: startDate, updatedAt: startDate,
logs: logs, logs: logs,
restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance,
targetHeartRate: routine.targetHeartRate
) )
await sync.save(workout: doc) await sync.save(workout: doc)
@@ -32,6 +32,8 @@ struct RoutineAddEditView: View {
@State private var autoAdvance: Bool = false @State private var autoAdvance: Bool = false
@State private var restOverrideEnabled: Bool = false @State private var restOverrideEnabled: Bool = false
@State private var restSecondsValue: Int = 45 @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 showingIconPicker: Bool = false
@State private var showingDeleteConfirmation: Bool = false @State private var showingDeleteConfirmation: Bool = false
@@ -48,6 +50,8 @@ struct RoutineAddEditView: View {
_autoAdvance = State(initialValue: routine.autoAdvance ?? false) _autoAdvance = State(initialValue: routine.autoAdvance ?? false)
_restOverrideEnabled = State(initialValue: routine.restSeconds != nil) _restOverrideEnabled = State(initialValue: routine.restSeconds != nil)
_restSecondsValue = State(initialValue: routine.restSeconds ?? 45) _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.") 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 { if let routine = routine {
Section(header: Text("Exercises")) { Section(header: Text("Exercises")) {
NavigationLink { NavigationLink {
@@ -205,6 +229,9 @@ struct RoutineAddEditView: View {
doc.activityType = activityType.rawValue doc.activityType = activityType.rawValue
doc.restSeconds = restOverrideEnabled ? restSecondsValue : nil doc.restSeconds = restOverrideEnabled ? restSecondsValue : nil
doc.autoAdvance = autoAdvance ? true : 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() doc.updatedAt = Date()
Task { await sync.save(routine: doc) } Task { await sync.save(routine: doc) }
} else { } else {
@@ -222,7 +249,9 @@ struct RoutineAddEditView: View {
exercises: [], exercises: [],
activityType: activityType.rawValue, activityType: activityType.rawValue,
restSeconds: restOverrideEnabled ? restSecondsValue : nil, restSeconds: restOverrideEnabled ? restSecondsValue : nil,
autoAdvance: autoAdvance ? true : nil autoAdvance: autoAdvance ? true : nil,
targetHeartRate: activityType.supportsHeartRateTarget && targetHREnabled
? targetHRValue : nil
) )
Task { await sync.save(routine: doc) } Task { await sync.save(routine: doc) }
} }
@@ -30,6 +30,10 @@ struct ExerciseProgressView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass @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 /// 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 /// 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) .padding(.bottom, 8)
} }
} }
.overlay(alignment: .top) {
heartRatePill
}
// Bottom half: the looping form-guide figure. Shows the *next* exercise while // 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 // 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 // MARK: - Adjust the last set's entry
/// The just-completed set's recorded entry, shown on the rest and finish pages /// 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 // MARK: - Phase Colors
private extension Color { private extension Color {