Hold a numeric count on the watch's Always-On timer

In the Always-On / wrist-down state, watchOS throttles the system timer
text and collapses sub-minute values to a relative "< 1 min", which
overflows the 50pt counter font and truncates with an ellipsis.

Gate PhaseTimerLayout on isLuminanceReduced: keep the live system timer
while active, but render our own held "~m:ss" snapshot from the wall-clock
anchors in Always-On — so the count stays numeric and on-screen. Timing is
unaffected (haptics/auto-advance still run off the anchor, not the text).

Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
This commit is contained in:
2026-06-21 10:04:39 -04:00
parent ce69aec988
commit ecf753fdec
2 changed files with 58 additions and 9 deletions
+2
View File
@@ -1,5 +1,7 @@
**June 2026** **June 2026**
During an Apple Watch workout, lowering your wrist now keeps the work or rest timer showing its most recent count (marked with a ~) instead of a cut-off "less than a minute".
Add a Workouts complication to your Apple Watch face — tap it to open the app straight from any watch face. Add a Workouts complication to your Apple Watch face — tap it to open the app straight from any watch face.
Prop your iPhone up during an Apple Watch workout and it now runs the same live flow side by side — Ready → work/rest → Finish with running timers — and you can drive from either device: swipe ahead, finish a set, or add one on whichever is closer, and the other follows along. Automatic moves, like a rest timer running out, advance both devices on their own. Prop your iPhone up during an Apple Watch workout and it now runs the same live flow side by side — Ready → work/rest → Finish with running timers — and you can drive from either device: swipe ahead, finish a set, or add one on whichever is closer, and the other follows along. Automatic moves, like a rest timer running out, advance both devices on their own.
@@ -584,15 +584,28 @@ private extension Color {
// MARK: - Phase Timer Layout // MARK: - Phase Timer Layout
/// The live timer a phase shows, described by its wall-clock anchors so `PhaseTimerLayout` can
/// render it live (system timer text, ticking each second) while active, but fall back to a
/// held "~m:ss" snapshot in the Always-On / wrist-down state where the system would otherwise
/// throttle the timer text and collapse sub-minute values to a truncated "< 1 min".
private enum PhaseTimer {
/// Count up from a wall-clock anchor (work-set stopwatch).
case countUp(from: Date)
/// Count down across a wall-clock window (rest / timed work set).
case countDown(from: Date, to: Date)
}
/// Shared skeleton for the work and rest pages so their timers use an identical font and /// Shared skeleton for the work and rest pages so their timers use an identical font and
/// land at exactly the same spot: a header line, the big timer, then a footer line. The /// land at exactly the same spot: a header line, the big timer, then a footer line. The
/// footer reserves its height even when empty, keeping the timer centered the same way on /// footer reserves its height even when empty, keeping the timer centered the same way on
/// both pages. /// both pages.
private struct PhaseTimerLayout<Content: View>: View { private struct PhaseTimerLayout: View {
let header: String let header: String
let footer: String let footer: String
let tint: Color let tint: Color
@ViewBuilder var timer: Content let timer: PhaseTimer
/// True in the Always-On / wrist-down state (dimmed screen).
@Environment(\.isLuminanceReduced) private var isLuminanceReduced
var body: some View { var body: some View {
VStack(spacing: 4) { VStack(spacing: 4) {
@@ -600,10 +613,12 @@ private struct PhaseTimerLayout<Content: View>: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
timer timerText
.font(.system(size: 50, weight: .bold, design: .rounded)) .font(.system(size: 50, weight: .bold, design: .rounded))
.monospacedDigit() .monospacedDigit()
.foregroundStyle(tint) .foregroundStyle(tint)
.lineLimit(1)
.minimumScaleFactor(0.5)
Text(footer.isEmpty ? " " : footer) Text(footer.isEmpty ? " " : footer)
.font(.caption) .font(.caption)
@@ -611,6 +626,40 @@ private struct PhaseTimerLayout<Content: View>: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
/// Live system timer text while active; a held "~m:ss" snapshot in Always-On, so the count
/// stays numeric and on-screen instead of collapsing to the system's truncated "< 1 min".
@ViewBuilder private var timerText: some View {
if isLuminanceReduced {
Text("~\(heldValue)")
} else {
switch timer {
case .countUp(let start):
Text(start, style: .timer)
case .countDown(let start, let end):
Text(timerInterval: start...end, countsDown: true)
}
}
}
/// The most-recent count as of this render, formatted to match the live timer.
private var heldValue: String {
switch timer {
case .countUp(let start):
return Self.clock(Date().timeIntervalSince(start))
case .countDown(_, let end):
return Self.clock(end.timeIntervalSinceNow)
}
}
/// Formats a (clamped-non-negative) interval as `m:ss` / `h:mm:ss`, matching the system
/// timer text's shape.
private static func clock(_ interval: TimeInterval) -> String {
let total = max(0, Int(interval.rounded()))
let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60)
return h > 0 ? String(format: "%d:%02d:%02d", h, m, s)
: String(format: "%d:%02d", m, s)
}
} }
// MARK: - Work Phase Dots // MARK: - Work Phase Dots
@@ -727,9 +776,8 @@ private struct WorkPhaseView: View {
@State private var startDate = Date() @State private var startDate = Date()
var body: some View { var body: some View {
PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) { PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer,
Text(startDate, style: .timer) timer: .countUp(from: startDate))
}
.onAppear { if isActive { restart(haptic: true) } } .onAppear { if isActive { restart(haptic: true) } }
.onChange(of: isActive) { _, active in if active { restart(haptic: true) } } .onChange(of: isActive) { _, active in if active { restart(haptic: true) } }
// A later frame for this same page re-anchors the timer without re-buzzing. // A later frame for this same page re-anchors the timer without re-buzzing.
@@ -776,9 +824,8 @@ private struct CountdownPhaseView: View {
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
PhaseTimerLayout(header: header, footer: footer, tint: tint) { PhaseTimerLayout(header: header, footer: footer, tint: tint,
Text(timerInterval: startDate...endDate, countsDown: true) timer: .countDown(from: startDate, to: endDate))
}
.onAppear { if isActive { start(haptic: true) } } .onAppear { if isActive { start(haptic: true) } }
.onChange(of: isActive) { _, active in if active { start(haptic: true) } } .onChange(of: isActive) { _, active in if active { start(haptic: true) } }
// A later frame for this same page re-anchors the window without re-buzzing. // A later frame for this same page re-anchors the window without re-buzzing.