From f2da47a70a918aac2009550915f9fe94690cc2e8 Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 20 Jun 2026 12:00:34 -0400 Subject: [PATCH] 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. --- CHANGELOG.md | 9 ++++ .../Views/ExerciseProgressView.swift | 54 ++++++++++++------- Workouts/Views/WorkoutLogs/ExerciseView.swift | 6 +++ 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07331e3..5d0b3e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project are documented here. ## 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 inadvertently universal but isn't an iPad-shaped experience. The Apple Watch app is a separate target and is unaffected. diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index 93bd608..897a33b 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -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) -} diff --git a/Workouts/Views/WorkoutLogs/ExerciseView.swift b/Workouts/Views/WorkoutLogs/ExerciseView.swift index 50e9264..15c32ea 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseView.swift @@ -10,6 +10,7 @@ import SwiftUI import SwiftData import Charts +import UIKit struct ExerciseView: View { @Environment(SyncEngine.self) private var sync @@ -66,6 +67,11 @@ struct ExerciseView: View { .onAppear { refreshDocIfNeeded() 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 // rewrites the whole workout file, so the cache always holds the latest — pulling