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