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
@@ -584,15 +584,28 @@ private extension Color {
// 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
/// 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
/// both pages.
private struct PhaseTimerLayout<Content: View>: View {
private struct PhaseTimerLayout: View {
let header: String
let footer: String
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 {
VStack(spacing: 4) {
@@ -600,10 +613,12 @@ private struct PhaseTimerLayout<Content: View>: View {
.font(.caption)
.foregroundStyle(.secondary)
timer
timerText
.font(.system(size: 50, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(tint)
.lineLimit(1)
.minimumScaleFactor(0.5)
Text(footer.isEmpty ? " " : footer)
.font(.caption)
@@ -611,6 +626,40 @@ private struct PhaseTimerLayout<Content: View>: View {
}
.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
@@ -727,9 +776,8 @@ private struct WorkPhaseView: View {
@State private var startDate = Date()
var body: some View {
PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) {
Text(startDate, style: .timer)
}
PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer,
timer: .countUp(from: startDate))
.onAppear { if isActive { 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.
@@ -776,9 +824,8 @@ private struct CountdownPhaseView: View {
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
var body: some View {
PhaseTimerLayout(header: header, footer: footer, tint: tint) {
Text(timerInterval: startDate...endDate, countsDown: true)
}
PhaseTimerLayout(header: header, footer: footer, tint: tint,
timer: .countDown(from: startDate, to: endDate))
.onAppear { if isActive { 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.