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