From 8854ad59d5f5ef4b0aa741d8622f21268f614382 Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 11 Jul 2026 19:51:44 -0400 Subject: [PATCH] Add per-routine target heart rate with live in-run indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 2 + README.md | 5 ++ SCHEMA.md | 6 ++ Shared/Connectivity/WCPayload.swift | 20 +++++++ Shared/Model/Documents.swift | 15 +++-- Shared/Model/Entities.swift | 12 ++++ Shared/Model/Enums.swift | 25 +++++++++ Shared/Model/Mappers.swift | 10 +++- .../WatchConnectivityBridge.swift | 22 ++++++++ .../Views/ExerciseProgressView.swift | 50 +++++++++++++++++ Workouts Watch App/WatchAppServices.swift | 3 + .../WorkoutSessionManager.swift | 10 +++- Workouts/Connectivity/LiveRunState.swift | 20 +++++++ .../PhoneConnectivityBridge.swift | 4 ++ Workouts/Views/Common/WorkoutStarter.swift | 3 +- .../Views/Routines/RoutineAddEditView.swift | 31 ++++++++++- .../WorkoutLogs/ExerciseProgressView.swift | 55 +++++++++++++++++++ 17 files changed, 283 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cc2537..e9660c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index efea401..cba0cd0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/SCHEMA.md b/SCHEMA.md index 2f3c3f5..4df17f7 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -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) diff --git a/Shared/Connectivity/WCPayload.swift b/Shared/Connectivity/WCPayload.swift index c843c6f..04c98c1 100644 --- a/Shared/Connectivity/WCPayload.swift +++ b/Shared/Connectivity/WCPayload.swift @@ -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) + } } diff --git a/Shared/Model/Documents.swift b/Shared/Model/Documents.swift index d5222ad..b3c7fc9 100644 --- a/Shared/Model/Documents.swift +++ b/Shared/Model/Documents.swift @@ -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 1→2 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 iPhone↔Watch wire) JSON keys @@ -139,6 +145,7 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable { case deletedLogIDs case restSeconds case autoAdvance + case targetHeartRate } // Bumped 1→2 when `metrics` was added: the captured HR/calorie data is diff --git a/Shared/Model/Entities.swift b/Shared/Model/Entities.swift index f58db9d..e4fd1b1 100644 --- a/Shared/Model/Entities.swift +++ b/Shared/Model/Entities.swift @@ -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] = [] diff --git a/Shared/Model/Enums.swift b/Shared/Model/Enums.swift index d0a1db7..10d7224 100644 --- a/Shared/Model/Enums.swift +++ b/Shared/Model/Enums.swift @@ -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 diff --git a/Shared/Model/Mappers.swift b/Shared/Model/Mappers.swift index d458c64..224cb4c 100644 --- a/Shared/Model/Mappers.swift +++ b/Shared/Model/Mappers.swift @@ -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() @@ -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() diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index 2c17381..d81eac5 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -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 diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index db4349e..1d41fae 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -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 diff --git a/Workouts Watch App/WatchAppServices.swift b/Workouts Watch App/WatchAppServices.swift index 0d98b6b..380b5e4 100644 --- a/Workouts Watch App/WatchAppServices.swift +++ b/Workouts Watch App/WatchAppServices.swift @@ -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 diff --git a/Workouts Watch App/WorkoutSessionManager.swift b/Workouts Watch App/WorkoutSessionManager.swift index ab69d65..af85028 100644 --- a/Workouts Watch App/WorkoutSessionManager.swift +++ b/Workouts Watch App/WorkoutSessionManager.swift @@ -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 (low→high). 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))? diff --git a/Workouts/Connectivity/LiveRunState.swift b/Workouts/Connectivity/LiveRunState.swift index 54a8ba5..77ae001 100644 --- a/Workouts/Connectivity/LiveRunState.swift +++ b/Workouts/Connectivity/LiveRunState.swift @@ -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? + 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 } diff --git a/Workouts/Connectivity/PhoneConnectivityBridge.swift b/Workouts/Connectivity/PhoneConnectivityBridge.swift index df45ee9..e32764b 100644 --- a/Workouts/Connectivity/PhoneConnectivityBridge.swift +++ b/Workouts/Connectivity/PhoneConnectivityBridge.swift @@ -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 } diff --git a/Workouts/Views/Common/WorkoutStarter.swift b/Workouts/Views/Common/WorkoutStarter.swift index 68e8642..de63b9c 100644 --- a/Workouts/Views/Common/WorkoutStarter.swift +++ b/Workouts/Views/Common/WorkoutStarter.swift @@ -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) diff --git a/Workouts/Views/Routines/RoutineAddEditView.swift b/Workouts/Views/Routines/RoutineAddEditView.swift index e00f47f..0f0f5d6 100644 --- a/Workouts/Views/Routines/RoutineAddEditView.swift +++ b/Workouts/Views/Routines/RoutineAddEditView.swift @@ -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) } } diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index bd07365..eda62f7 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -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 {