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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (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))?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user