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
|
## 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
|
||||||
|
|||||||
Reference in New Issue
Block a user