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
+9
View File
@@ -4,6 +4,15 @@ All notable changes to this project are documented here.
## June 2026 ## June 2026
- Fixed the Apple Watch work/rest timers freezing when the wrist was lowered. They
counted by incrementing a per-second `Timer`, which watchOS throttles in the
Always-On (dimmed) state; they now derive from a wall-clock anchor rendered with
SwiftUI's self-updating timer text, so the time keeps advancing while dimmed and is
correct the instant the wrist comes back up. Rest haptics and auto-advance are
driven off the end time too, so they catch up after a stall instead of stalling.
- Keep the iPhone screen awake while the exercise detail screen is open, so the
display no longer sleeps mid-set. (The Apple Watch already stays awake during a
workout via its HealthKit workout session.)
- Set the iPhone app to iPhone-only (`TARGETED_DEVICE_FAMILY` 1); it was - Set the iPhone app to iPhone-only (`TARGETED_DEVICE_FAMILY` 1); it was
inadvertently universal but isn't an iPad-shaped experience. The Apple Watch app inadvertently universal but isn't an iPad-shaped experience. The Apple Watch app
is a separate target and is unaffected. is a separate target and is unaffected.
@@ -249,8 +249,11 @@ private struct WorkPhaseView: View {
let onOneMore: () -> Void let onOneMore: () -> Void
let onDone: () -> Void let onDone: () -> Void
@State private var elapsed = 0 /// Wall-clock anchor for the count-up stopwatch. Driving the display from a fixed
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() /// 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 { var body: some View {
VStack(spacing: 6) { VStack(spacing: 6) {
@@ -258,7 +261,7 @@ private struct WorkPhaseView: View {
.font(.headline) .font(.headline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text(clockString(elapsed)) Text(startDate, style: .timer)
.font(.system(size: 48, weight: .bold, design: .rounded)) .font(.system(size: 48, weight: .bold, design: .rounded))
.monospacedDigit() .monospacedDigit()
.foregroundStyle(.green) .foregroundStyle(.green)
@@ -292,11 +295,10 @@ private struct WorkPhaseView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { if isActive { restart() } } .onAppear { if isActive { restart() } }
.onChange(of: isActive) { _, active in if active { restart() } } .onChange(of: isActive) { _, active in if active { restart() } }
.onReceive(ticker) { _ in if isActive { elapsed += 1 } }
} }
private func restart() { private func restart() {
elapsed = 0 startDate = Date()
WKInterfaceDevice.current().play(.start) WKInterfaceDevice.current().play(.start)
} }
} }
@@ -309,7 +311,19 @@ private struct RestPhaseView: View {
let onFinished: () -> Void let onFinished: () -> Void
@AppStorage("restSeconds") private var restSeconds: Int = 45 @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() private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
@@ -318,7 +332,7 @@ private struct RestPhaseView: View {
.font(.headline) .font(.headline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text(clockString(max(0, remaining))) Text(timerInterval: startDate...endDate, countsDown: true)
.font(.system(size: 54, weight: .bold, design: .rounded)) .font(.system(size: 54, weight: .bold, design: .rounded))
.monospacedDigit() .monospacedDigit()
.foregroundStyle(.orange) .foregroundStyle(.orange)
@@ -334,27 +348,29 @@ private struct RestPhaseView: View {
} }
private func start() { 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) WKInterfaceDevice.current().play(.start)
} }
private func tick() { private func tick() {
guard isActive, remaining > 0 else { return } guard isActive, !didFinish else { return }
remaining -= 1 // Round up so the final whole second still pings before we reach zero.
let remaining = Int(ceil(endDate.timeIntervalSinceNow))
if remaining == 0 { if remaining <= 0 {
// Time's up final cue and slide to the next work phase. // 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) WKInterfaceDevice.current().play(.stop)
onFinished() onFinished()
} else if remaining <= 3 { } else if remaining <= 3 && remaining < lastPingSecond {
// Once-per-second countdown ping for the final three seconds. // Once-per-second countdown ping for the final three seconds.
lastPingSecond = remaining
WKInterfaceDevice.current().play(.notification) WKInterfaceDevice.current().play(.notification)
} }
} }
} }
// MARK: - Shared
private func clockString(_ seconds: Int) -> String {
String(format: "%d:%02d", seconds / 60, seconds % 60)
}
@@ -10,6 +10,7 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import Charts import Charts
import UIKit
struct ExerciseView: View { struct ExerciseView: View {
@Environment(SyncEngine.self) private var sync @Environment(SyncEngine.self) private var sync
@@ -66,6 +67,11 @@ struct ExerciseView: View {
.onAppear { .onAppear {
refreshDocIfNeeded() refreshDocIfNeeded()
progress = log?.currentStateIndex ?? 0 progress = log?.currentStateIndex ?? 0
// Keep the screen lit while logging sets a mid-workout sleep is annoying.
UIApplication.shared.isIdleTimerDisabled = true
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
} }
// Reflect external changes (e.g. a set completed on the watch) live. Each edit // Reflect external changes (e.g. a set completed on the watch) live. Each edit
// rewrites the whole workout file, so the cache always holds the latest pulling // rewrites the whole workout file, so the cache always holds the latest pulling