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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user