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**
|
**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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 1→2 when the weight-reminder fields (`weightLastUpdated`,
|
// Bumped 1→2 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 iPhone↔Watch wire) JSON keys
|
// 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 deletedLogIDs
|
||||||
case restSeconds
|
case restSeconds
|
||||||
case autoAdvance
|
case autoAdvance
|
||||||
|
case targetHeartRate
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bumped 1→2 when `metrics` was added: the captured HR/calorie data is
|
// 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
|
// 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] = []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (low→high). Built on the
|
/// 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
|
/// 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))?
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user