Keep the screen awake during workouts; fix watch timers freezing

- iPhone: disable the idle timer while the exercise detail screen is open,
  so the display no longer sleeps mid-set.
- Watch: the work/rest timers counted on a run-loop Timer that watchOS
  throttles in the Always-On (wrist-down) state, so they froze. Anchor both
  to a wall-clock Date rendered with SwiftUI's self-updating timer text;
  rest haptics + auto-advance now derive from the end time so they catch up
  after a stall instead of stalling.
This commit is contained in:
2026-06-20 12:00:34 -04:00
parent 90271952f3
commit f2da47a70a
3 changed files with 50 additions and 19 deletions
@@ -249,8 +249,11 @@ private struct WorkPhaseView: View {
let onOneMore: () -> Void
let onDone: () -> Void
@State private var elapsed = 0
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
/// Wall-clock anchor for the count-up stopwatch. Driving the display from a fixed
/// start date (rendered by SwiftUI's timer text) instead of incrementing a counter
/// on a run-loop `Timer` keeps it advancing in the Always-On / wrist-down state,
/// where that timer is throttled and stops firing.
@State private var startDate = Date()
var body: some View {
VStack(spacing: 6) {
@@ -258,7 +261,7 @@ private struct WorkPhaseView: View {
.font(.headline)
.foregroundStyle(.secondary)
Text(clockString(elapsed))
Text(startDate, style: .timer)
.font(.system(size: 48, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.green)
@@ -292,11 +295,10 @@ private struct WorkPhaseView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { if isActive { restart() } }
.onChange(of: isActive) { _, active in if active { restart() } }
.onReceive(ticker) { _ in if isActive { elapsed += 1 } }
}
private func restart() {
elapsed = 0
startDate = Date()
WKInterfaceDevice.current().play(.start)
}
}
@@ -309,7 +311,19 @@ private struct RestPhaseView: View {
let onFinished: () -> Void
@AppStorage("restSeconds") private var restSeconds: Int = 45
@State private var remaining = 0
/// Wall-clock window for the countdown. SwiftUI renders the remaining time from this
/// range (so the display keeps counting down in the Always-On / wrist-down state),
/// and the haptics + auto-advance below are derived from `endDate` rather than a
/// decremented counter so they stay correct even after the run-loop `Timer` was
/// throttled while the wrist was down.
@State private var startDate = Date()
@State private var endDate = Date()
/// Lowest remaining-second we've already pinged, so a burst of catch-up ticks after a
/// stall doesn't double-beep.
@State private var lastPingSecond = Int.max
/// Guards the auto-advance so it fires exactly once even if ticks pile up.
@State private var didFinish = false
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
var body: some View {
@@ -318,7 +332,7 @@ private struct RestPhaseView: View {
.font(.headline)
.foregroundStyle(.secondary)
Text(clockString(max(0, remaining)))
Text(timerInterval: startDate...endDate, countsDown: true)
.font(.system(size: 54, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.orange)
@@ -334,27 +348,29 @@ private struct RestPhaseView: View {
}
private func start() {
remaining = max(1, restSeconds)
startDate = Date()
endDate = startDate.addingTimeInterval(Double(max(1, restSeconds)))
lastPingSecond = Int.max
didFinish = false
WKInterfaceDevice.current().play(.start)
}
private func tick() {
guard isActive, remaining > 0 else { return }
remaining -= 1
guard isActive, !didFinish else { return }
// Round up so the final whole second still pings before we reach zero.
let remaining = Int(ceil(endDate.timeIntervalSinceNow))
if remaining == 0 {
// Time's up final cue and slide to the next work phase.
if remaining <= 0 {
// Time's up final cue and slide to the next work phase. If the wrist was
// down the timer may have stalled; this then fires on the first tick once the
// app gets runtime again (e.g. the wrist comes up), never losing the rest.
didFinish = true
WKInterfaceDevice.current().play(.stop)
onFinished()
} else if remaining <= 3 {
} else if remaining <= 3 && remaining < lastPingSecond {
// Once-per-second countdown ping for the final three seconds.
lastPingSecond = remaining
WKInterfaceDevice.current().play(.notification)
}
}
}
// MARK: - Shared
private func clockString(_ seconds: Int) -> String {
String(format: "%d:%02d", seconds / 60, seconds % 60)
}