diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a5574..e30bac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **July 2026** +A live status panel between the timer and the figure now shows heart rate (with the target color cues, in much larger type), HR zone, calories, and workout time. + A new Library tab puts your routines and the exercise library one tap away instead of buried in Settings. Drag routines into your own order — the Today board and routine pickers follow it everywhere. diff --git a/README.md b/README.md index 2946453..c9f97a1 100644 --- a/README.md +++ b/README.md @@ -93,11 +93,14 @@ 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. +- **Live status panel** — between the run screen's timer and figure (a band in + portrait, a column in landscape) sits a big-digit readout of watch-streamed heart + rate, HR zone (1–5), active calories, and the workout stopwatch, glanceable from + across the room. - **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. + and the live heart-rate readout — on the watch and in the iPhone's status panel — + tints 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/Shared/Connectivity/WCPayload.swift b/Shared/Connectivity/WCPayload.swift index 04c98c1..f127eee 100644 --- a/Shared/Connectivity/WCPayload.swift +++ b/Shared/Connectivity/WCPayload.swift @@ -149,13 +149,21 @@ enum WCPayload { // the readout refreshes on the next one. static let hrBpmKey = "hrBpm" static let hrAtKey = "hrAt" + static let hrKcalKey = "hrKcal" + static let hrZoneKey = "hrZone" - static func encodeLiveHeartRate(bpm: Double, at date: Date) -> [String: Any] { - [typeKey: liveHeartRateType, hrBpmKey: bpm, hrAtKey: date] + /// The session's running calories and HR zone ride along with each sample when + /// known — absent keys (an older watch, or no energy sample yet) decode as nil, + /// so the two builds stay wire-compatible in both directions. + static func encodeLiveHeartRate(bpm: Double, at date: Date, kcal: Double? = nil, zone: Int? = nil) -> [String: Any] { + var dict: [String: Any] = [typeKey: liveHeartRateType, hrBpmKey: bpm, hrAtKey: date] + if let kcal { dict[hrKcalKey] = kcal } + if let zone { dict[hrZoneKey] = zone } + return dict } - static func decodeLiveHeartRate(_ dict: [String: Any]) -> (bpm: Double, at: Date)? { + static func decodeLiveHeartRate(_ dict: [String: Any]) -> (bpm: Double, at: Date, kcal: Double?, zone: Int?)? { guard let bpm = dict[hrBpmKey] as? Double, let at = dict[hrAtKey] as? Date else { return nil } - return (bpm, at) + return (bpm, at, dict[hrKcalKey] as? Double, dict[hrZoneKey] as? Int) } } diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index d81eac5..107dadf 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -221,22 +221,28 @@ final class WatchConnectivityBridge: NSObject { /// 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. + /// staleness window doesn't blank the readout mid-run. A zone flip breaks the throttle + /// so the panel's zone can't lag a boundary crossing by the keep-alive interval. private var lastHRSentBpm: Int? + private var lastHRSentZone: 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) { + /// Forward a live sample (heart rate + running calories + zone) 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 summary still lands in `WorkoutMetrics` + /// when the session finishes. + func sendLiveSample(_ sample: WorkoutSessionManager.LiveSample) { guard let session, session.activationState == .activated, session.isReachable else { return } - let rounded = Int(bpm.rounded()) + let rounded = Int(sample.bpm.rounded()) let now = Date() - if rounded == lastHRSentBpm, now.timeIntervalSince(lastHRSentAt) < 10 { return } + if rounded == lastHRSentBpm, sample.zone == lastHRSentZone, + now.timeIntervalSince(lastHRSentAt) < 10 { return } lastHRSentBpm = rounded + lastHRSentZone = sample.zone lastHRSentAt = now - session.sendMessage(WCPayload.encodeLiveHeartRate(bpm: bpm, at: now), - replyHandler: nil, errorHandler: nil) + session.sendMessage( + WCPayload.encodeLiveHeartRate(bpm: sample.bpm, at: now, kcal: sample.kcal, zone: sample.zone), + replyHandler: nil, errorHandler: nil) } /// Apply a live-run frame the phone sent. Catches our send counter up first (shared per-run diff --git a/Workouts Watch App/WatchAppServices.swift b/Workouts Watch App/WatchAppServices.swift index 380b5e4..327f8f6 100644 --- a/Workouts Watch App/WatchAppServices.swift +++ b/Workouts Watch App/WatchAppServices.swift @@ -36,9 +36,10 @@ 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) } + // Forward each live sample (HR + calories + zone) to a mirroring phone + // (best-effort, display-only). No cycle: the bridge never references the + // session manager back. + sessionManager.onHeartRateSample = { [bridge] sample in bridge.sendLiveSample(sample) } 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 af85028..4d61bc8 100644 --- a/Workouts Watch App/WorkoutSessionManager.swift +++ b/Workouts Watch App/WorkoutSessionManager.swift @@ -38,10 +38,19 @@ final class WorkoutSessionManager: NSObject { private(set) var currentHeartRate: Double? private(set) var currentActiveEnergyKcal: Double? + /// One live reading for the phone's status panel: the fresh heart rate plus the + /// session's running calorie total and the HR zone (1–5) that rate falls in. + /// `kcal`/`zone` are nil while unknown (no energy sample yet / max HR unknown). + struct LiveSample { + let bpm: Double + let kcal: Double? + let zone: Int? + } + /// 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)? + var onHeartRateSample: ((LiveSample) -> 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 @@ -187,14 +196,17 @@ 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 - onHeartRateSample?(newHR) - } - lastHRSampleDate = now + // Refresh the calorie total before forwarding, so the sample carries it. currentActiveEnergyKcal = builder?.statistics(for: HKQuantityType(.activeEnergyBurned))? .sumQuantity()?.doubleValue(for: .kilocalorie()) + + if let newHR { + currentHeartRate = newHR + let zone = maxHeartRate.map { Self.zoneIndex(for: newHR, maxHR: $0) + 1 } + onHeartRateSample?(LiveSample(bpm: newHR, kcal: currentActiveEnergyKcal, zone: zone)) + } + lastHRSampleDate = now } /// Bucket an instantaneous heart rate into one of five zones (0–4) by its fraction of the diff --git a/Workouts/Connectivity/LiveRunState.swift b/Workouts/Connectivity/LiveRunState.swift index 77ae001..26e496c 100644 --- a/Workouts/Connectivity/LiveRunState.swift +++ b/Workouts/Connectivity/LiveRunState.swift @@ -40,17 +40,30 @@ final class LiveRunState { /// the watch stops sending (session ended, unreachable). private(set) var heartRate: Double? + /// Running active-calorie total for the watch session, riding along with each HR + /// sample. Nil until the first energy sample (or from an older watch build). + private(set) var activeEnergyKcal: Double? + + /// HR zone (1–5) the current rate falls in, computed on the watch (the only place + /// that knows max HR). Nil when the wearer's age is unknown. + private(set) var hrZone: Int? + 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) { + /// Apply an incoming live sample and (re)arm the shared staleness expiry — the + /// three values arrive together, so they go stale together. + func applyLiveSample(bpm: Double, kcal: Double?, zone: Int?) { heartRate = bpm + activeEnergyKcal = kcal + hrZone = zone heartRateExpiry?.cancel() heartRateExpiry = Task { [weak self] in try? await Task.sleep(for: Self.hrStaleAfter) guard !Task.isCancelled else { return } self?.heartRate = nil + self?.activeEnergyKcal = nil + self?.hrZone = nil } } diff --git a/Workouts/Connectivity/PhoneConnectivityBridge.swift b/Workouts/Connectivity/PhoneConnectivityBridge.swift index e32764b..2a19324 100644 --- a/Workouts/Connectivity/PhoneConnectivityBridge.swift +++ b/Workouts/Connectivity/PhoneConnectivityBridge.swift @@ -269,7 +269,9 @@ final class PhoneConnectivityBridge: NSObject { } case WCPayload.liveHeartRateType: if let sample = WCPayload.decodeLiveHeartRate(dict) { - Task { @MainActor in self.liveRunState.applyHeartRate(sample.bpm) } + Task { @MainActor in + self.liveRunState.applyLiveSample(bpm: sample.bpm, kcal: sample.kcal, zone: sample.zone) + } } default: break diff --git a/Workouts/Screenshots/ScreenshotRootView.swift b/Workouts/Screenshots/ScreenshotRootView.swift index 6e0adbe..c9974a1 100644 --- a/Workouts/Screenshots/ScreenshotRootView.swift +++ b/Workouts/Screenshots/ScreenshotRootView.swift @@ -43,6 +43,8 @@ struct ScreenshotRootView: View { NavigationStack { ExerciseView(workout: workout, logID: logID ?? "") } case "run": ScreenshotRunView(workout: workout) + // Believable watch-fed values so the status panel isn't placeholders. + .onAppear { services.liveRunState.applyLiveSample(bpm: 128, kcal: 236, zone: 3) } case "settings": SettingsView() default: diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index 5b5de5c..d79dc12 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -24,16 +24,18 @@ import UIKit /// /// The paged flow occupies the **top half** of the screen; the bottom half shows the /// animated form-guide figure when the exercise has a bundled motion -/// (`ExerciseFigureSlot`). A row of phase dots tracks progress: purple for work, -/// teal for rest, with the current phase drawn as a wider dash. +/// (`ExerciseFigureSlot`). Between the two halves sits the **live status panel** — +/// heart rate (tinted by the target band on cardio runs), HR zone, calories, and the +/// workout stopwatch in big glanceable digits. A row of phase dots tracks progress: +/// purple for work, teal for rest, with the current phase drawn as a wider dash. struct ExerciseProgressView: View { @Environment(\.dismiss) private var dismiss @Environment(\.verticalSizeClass) private var verticalSizeClass @Environment(\.modelContext) private var modelContext @Environment(SyncEngine.self) private var sync - /// Live watch-forwarded heart rate (see `LiveRunState.heartRate`) — drives the - /// target-HR pill when this run carries a `targetHeartRate`. + /// Live watch-forwarded metrics (heart rate, calories, zone — see `LiveRunState`) + /// — they drive the status panel between the two halves. @Environment(LiveRunState.self) private var liveRun @@ -468,9 +470,9 @@ struct ExerciseProgressView: View { .padding(.bottom, 8) } } - .overlay(alignment: .top) { - heartRatePill - } + + // The live status panel sits between the halves in both orientations. + statusPanel // 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 @@ -851,30 +853,89 @@ struct ExerciseProgressView: View { } } - // MARK: - Live heart rate vs. target + // MARK: - Live status panel - /// 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. + /// Scales the panel's metric digits with Dynamic Type. + @ScaledMetric(relativeTo: .title) private var metricFontSize: CGFloat = 34 + + /// When the workout actually began: the earliest started exercise, else the stamp + /// the workout was created with (a run still sitting on its first Ready page). + private var workoutStartAnchor: Date { + doc.logs.compactMap(\.startedAt).min() ?? doc.start + } + + /// The status strip between the two halves: live heart rate (tinted by the target + /// band, with the ease-off/push-harder cue, when the run carries a target), HR + /// zone, and active calories — all watch-fed, dimmed to a placeholder until + /// samples flow — plus the always-ticking workout stopwatch. A horizontal band in + /// portrait, a vertical column in landscape: the *inverse* of `splitLayout`, so it + /// always sits between the halves. @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) + private var statusPanel: some View { + Group { + if verticalSizeClass == .compact { + VStack(spacing: 22) { statusMetrics(expand: false) } + .padding(.horizontal, 10) + .frame(maxHeight: .infinity) + } else { + HStack(spacing: 0) { statusMetrics(expand: true) } + .padding(.vertical, 8) + .padding(.horizontal, 12) } - .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) + } + .animation(.snappy(duration: 0.25), value: liveRun.heartRate) + .animation(.snappy(duration: 0.25), value: liveRun.activeEnergyKcal) + .animation(.snappy(duration: 0.25), value: liveRun.hrZone) + } + + /// The four metric cells, in fixed order. `expand` spreads them evenly across the + /// portrait band; the landscape column keeps intrinsic widths. + @ViewBuilder + private func statusMetrics(expand: Bool) -> some View { + Group { + heartRateMetric + metricCell(liveRun.hrZone.map { Text(String($0)) }, caption: "ZONE") + metricCell(liveRun.activeEnergyKcal.map { Text("\(Int($0.rounded()))") }, caption: "KCAL") + metricCell(Text(workoutStartAnchor, style: .timer), caption: "ELAPSED") + } + .frame(maxWidth: expand ? .infinity : nil) + } + + /// Heart rate, tinted by band with the correction cue — ease off (arrow down) or + /// push harder (arrow up) — when the run carries a target; neutral otherwise, so + /// the readout is still useful on a strength run with a watch session going. + private var heartRateMetric: some View { + let band = doc.targetHeartRate.flatMap { target in + liveRun.heartRate.map { HeartRateBand(bpm: $0, target: target) } + } + return metricCell( + liveRun.heartRate.map { Text("\(Int($0.rounded()))") }, + caption: "BPM", + cue: band?.cueSymbol, + tint: band?.tint ?? .primary) + } + + /// One panel cell: big rounded digits over a small caption. A nil value renders as + /// a dimmed placeholder so the layout never jumps as watch samples start flowing. + private func metricCell(_ value: Text?, caption: String, cue: String? = nil, tint: Color = .primary) -> some View { + VStack(spacing: 2) { + HStack(spacing: 5) { + (value ?? Text(verbatim: "–")) + .monospacedDigit() + .contentTransition(.numericText()) + if let cue { + Image(systemName: cue) + .font(.system(size: metricFontSize * 0.5, weight: .bold)) + } + } + .font(.system(size: metricFontSize, weight: .bold, design: .rounded)) + .foregroundStyle(value == nil ? AnyShapeStyle(.quaternary) : AnyShapeStyle(tint)) + .lineLimit(1) + .minimumScaleFactor(0.6) + + Text(caption) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) } }