Give the exercise run screen its own chrome, rolling digits, and a true resume
The run screen now hides the navigation bar in both orientations and draws its own header — back chevron top-left, exercise name (plus library name when renamed) in large type where the toolbar was, previewing the next exercise during a between-exercise rest. Work and rest counters are pinned to the exact vertical center of the timer half via equal flexible bands, with the adjust pill moved to an overlay so complications can't shift them. Counter digits are now state-driven and roll through numericText transitions (work count-up, rest countdown, and the Done button count). Reopening an in-progress exercise now resumes its timer from the durable timestamps instead of restarting at zero: no sets done anchors the stopwatch to startedAt; mid-rest lands back on the rest page with the countdown still running off its true window; past the window the next set's stopwatch counts from the rest's computed end. The resume page is computed at init because the paged TabView must initialize on it — a post-layout backward jump wedges the pager, which then ignores the next animated programmatic advance. Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
**July 2026**
|
||||
|
||||
The exercise screen now shows the exercise name in large type with a simple back chevron in place of the toolbar, in portrait and landscape alike.
|
||||
|
||||
Work and rest counters now sit at the exact same spot on screen, so the digits no longer shift between phases.
|
||||
|
||||
Timer digits now roll smoothly as they count up or down.
|
||||
|
||||
Reopening an exercise that's already underway now continues its timer where it left off — even mid-rest — instead of restarting from zero.
|
||||
|
||||
A routine can now include the same exercise more than once — swipe right on an exercise to duplicate it for interval-style segments.
|
||||
|
||||
Exercises can now be renamed per routine, like "Warmup Walk" for a treadmill segment, while their animated figure and guide still follow the original exercise.
|
||||
|
||||
@@ -158,7 +158,16 @@ struct ExerciseProgressView: View {
|
||||
/// remote-flip observer doesn't mistake our own terminal write for a peer's.
|
||||
@State private var locallyResolved = false
|
||||
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, speechAnnouncer: SpeechAnnouncer? = nil, enteredViaFlow: Bool = false, onAdvance: ((String) -> Void)? = nil) {
|
||||
/// Where the resume landed, computed once on appear (with its timer anchor) so the
|
||||
/// post-layout re-assert jumps to the same page the anchor was built for.
|
||||
@State private var resumeTargetPage: Int?
|
||||
|
||||
/// The leading header button. The default chevron dismisses like a nav back button;
|
||||
/// hosts that present modally (the live-mirror cover) swap in their own symbol/action.
|
||||
var closeSymbol: String = "chevron.backward"
|
||||
var onClose: (() -> Void)? = nil
|
||||
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, speechAnnouncer: SpeechAnnouncer? = nil, enteredViaFlow: Bool = false, onAdvance: ((String) -> Void)? = nil, closeSymbol: String = "chevron.backward", onClose: (() -> Void)? = nil) {
|
||||
self._doc = doc
|
||||
self.logID = logID
|
||||
self.onChange = onChange
|
||||
@@ -170,6 +179,8 @@ struct ExerciseProgressView: View {
|
||||
self.speechAnnouncer = speechAnnouncer
|
||||
self.enteredViaFlow = enteredViaFlow
|
||||
self.onAdvance = onAdvance
|
||||
self.closeSymbol = closeSymbol
|
||||
self.onClose = onClose
|
||||
|
||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||
let sets = max(1, log?.sets ?? 1)
|
||||
@@ -190,6 +201,18 @@ struct ExerciseProgressView: View {
|
||||
let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1)
|
||||
let resume = base + completed * 2
|
||||
_currentPage = State(initialValue: notStarted ? 0 : resume)
|
||||
|
||||
// A resumed run refines the landing to the phase the exercise is actually in
|
||||
// (mid-rest lands on the rest page) with its wall-clock anchor, so the timer
|
||||
// continues instead of restarting. Computed here — not on appear — because the
|
||||
// paged TabView must *initialize* on this page: a post-layout backward jump
|
||||
// wedges its pager, which then ignores the next animated programmatic advance.
|
||||
if !notStarted, log?.status == WorkoutStatus.inProgress.rawValue {
|
||||
let refined = computeResume()
|
||||
_currentPage = State(initialValue: refined.page)
|
||||
_resumeTargetPage = State(initialValue: refined.page)
|
||||
_pageAnchor = State(initialValue: refined.anchor)
|
||||
}
|
||||
}
|
||||
|
||||
private var log: WorkoutLogDocument? {
|
||||
@@ -333,62 +356,76 @@ struct ExerciseProgressView: View {
|
||||
: AnyLayout(VStackLayout(spacing: 0))
|
||||
}
|
||||
|
||||
private var isLandscape: Bool { verticalSizeClass == .compact }
|
||||
/// Scales the header exercise-name headline with Dynamic Type.
|
||||
@ScaledMetric(relativeTo: .largeTitle) private var headerNameFontSize: CGFloat = 30
|
||||
|
||||
/// Scales the landscape exercise-name headline with Dynamic Type.
|
||||
@ScaledMetric(relativeTo: .largeTitle) private var landscapeNameFontSize: CGFloat = 30
|
||||
|
||||
/// Portrait reads the exercise name from the nav bar; landscape's compact bar makes
|
||||
/// it tiny — too small for a phone propped at arm's length — so there the title is
|
||||
/// blanked and the figure half spells the name out in large type instead. During a
|
||||
/// between-exercise rest the figure previews the *next* exercise, so the headline
|
||||
/// names what's shown.
|
||||
private var navBarTitle: String {
|
||||
isLandscape ? "" : (log?.exerciseName ?? "")
|
||||
/// The library name shown under the headline — only once an exercise was renamed,
|
||||
/// so the form-guide's identity stays visible next to the custom name.
|
||||
private var headerLibrarySubtitle: String? {
|
||||
let library = figureLibraryExerciseName
|
||||
guard !library.isEmpty, library != figureExerciseName else { return nil }
|
||||
return library
|
||||
}
|
||||
|
||||
/// The figure half of the split, with the big-type exercise headline above the
|
||||
/// figure in landscape. `display` names the headline; `figure` is the library
|
||||
/// name the animated rig is keyed on — they diverge once an exercise is renamed.
|
||||
@ViewBuilder
|
||||
private func figureHalf(display: String, figure: String) -> some View {
|
||||
if isLandscape {
|
||||
/// Replaces the navigation bar in both orientations: the back chevron top-left and
|
||||
/// the exercise name — big type readable from a propped phone — where the bar was.
|
||||
/// During a between-exercise rest it previews the *next* exercise, matching the
|
||||
/// figure below.
|
||||
private var headerBar: some View {
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
Text(display)
|
||||
.font(.system(size: landscapeNameFontSize, weight: .bold, design: .rounded))
|
||||
Text(figureExerciseName)
|
||||
.font(.system(size: headerNameFontSize, weight: .bold, design: .rounded))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
ExerciseFigureSlot(exerciseName: figure)
|
||||
if let headerLibrarySubtitle {
|
||||
Text(headerLibrarySubtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 52) // stay clear of the chevron, centered on screen
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
if let onClose { onClose() } else { dismiss() }
|
||||
} label: {
|
||||
Image(systemName: closeSymbol)
|
||||
.font(.title3.weight(.semibold))
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.accessibilityLabel("Back")
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
ExerciseFigureSlot(exerciseName: figure)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
VStack(spacing: 0) {
|
||||
headerBar
|
||||
if startsCompleted {
|
||||
// Same half-and-half split as the run flow, with the Completed badge where
|
||||
// the paged flow would be, so the form-guide figure stays on screen.
|
||||
splitLayout {
|
||||
CompletedPhaseView(log: log)
|
||||
figureHalf(display: log?.exerciseName ?? "", figure: log?.libraryExerciseName ?? "")
|
||||
ExerciseFigureSlot(exerciseName: log?.libraryExerciseName ?? "")
|
||||
}
|
||||
.navigationTitle(navBarTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} else if startsSkipped {
|
||||
splitLayout {
|
||||
SkippedPhaseView()
|
||||
figureHalf(display: log?.exerciseName ?? "", figure: log?.libraryExerciseName ?? "")
|
||||
ExerciseFigureSlot(exerciseName: log?.libraryExerciseName ?? "")
|
||||
}
|
||||
.navigationTitle(navBarTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} else {
|
||||
flowBody
|
||||
}
|
||||
}
|
||||
// The flow owns its chrome: the nav bar is hidden in both orientations (the
|
||||
// header above carries the name and back chevron in its place).
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
// Landscape's side-by-side split is already starved for height — hide the tab
|
||||
// bar there and give the row the full screen. Portrait keeps it.
|
||||
.toolbarVisibility(verticalSizeClass == .compact ? .hidden : .automatic, for: .tabBar)
|
||||
@@ -419,10 +456,8 @@ struct ExerciseProgressView: View {
|
||||
// resting between exercises (a live preview of what's coming up), otherwise this
|
||||
// one. The name swap re-loads the figure and carries through the hand-off, so it
|
||||
// stays put as the next exercise takes over.
|
||||
figureHalf(display: figureExerciseName, figure: figureLibraryExerciseName)
|
||||
ExerciseFigureSlot(exerciseName: figureLibraryExerciseName)
|
||||
}
|
||||
.navigationTitle(navBarTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onChange(of: currentPage) { oldPage, newPage in
|
||||
// Ignore page changes until the initial resume settles, so the TabView's
|
||||
// transient snap-to-0 on first layout can't reset an in-progress run.
|
||||
@@ -473,10 +508,11 @@ struct ExerciseProgressView: View {
|
||||
.onAppear {
|
||||
guard !didRestorePage else { return }
|
||||
if startsResumed {
|
||||
// Resume on the first unfinished set. A paged TabView can settle on page 0
|
||||
// on first layout, so re-assert the resume page (now and once more after
|
||||
// this run loop) before honoring page changes — otherwise that snap would
|
||||
// land on, and reset at, the Ready page.
|
||||
// Resume on the page computed at init (with its wall-clock anchor). A
|
||||
// paged TabView can settle on page 0 on first layout, so re-assert the
|
||||
// resume page (now and once more after this run loop) before honoring
|
||||
// page changes — otherwise that snap would land on, and reset at, the
|
||||
// Ready page.
|
||||
jumpToResumePage()
|
||||
Task { @MainActor in
|
||||
jumpToResumePage()
|
||||
@@ -510,13 +546,54 @@ struct ExerciseProgressView: View {
|
||||
/// Move to the resume page without animation, only if we're not already there
|
||||
/// (so a re-assert after a TabView snap-to-0 is a no-op in the common case).
|
||||
private func jumpToResumePage() {
|
||||
let target = resumePage
|
||||
let target = resumeTargetPage ?? resumePage
|
||||
guard currentPage != target else { return }
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) { currentPage = target }
|
||||
}
|
||||
|
||||
/// Where a resumed run re-opens: the page plus the wall-clock anchor that lets its
|
||||
/// timer continue from where the exercise actually is, rebuilt from the durable
|
||||
/// timestamps (there is no ephemeral state to restore — leaving the screen tears the
|
||||
/// live flow down).
|
||||
///
|
||||
/// • No sets recorded yet → the first work page, its stopwatch anchored to
|
||||
/// `startedAt` so it keeps counting.
|
||||
/// • k sets recorded and the rest after set k is still running (the k-th entry is
|
||||
/// stamped as its rest begins — see `recordProgress`) → that rest page, anchored
|
||||
/// to its true window so the countdown resumes mid-flight and still auto-advances
|
||||
/// at the real boundary.
|
||||
/// • Otherwise → set k+1's work page, anchored at the rest's computed end.
|
||||
///
|
||||
/// Logs without the timestamps (older files) fall back to today's behavior: the
|
||||
/// first unfinished set's work page, self-anchored at local `now`.
|
||||
private func computeResume(now: Date = Date()) -> (page: Int, anchor: PageAnchor?) {
|
||||
let fallback = resumePage
|
||||
guard let log else { return (fallback, nil) }
|
||||
let completed = min(max(0, log.currentStateIndex), setCount - 1)
|
||||
|
||||
func workAnchor(page: Int, start: Date) -> PageAnchor {
|
||||
PageAnchor(page: page, start: start,
|
||||
end: isDuration ? start.addingTimeInterval(Double(workDurationSeconds)) : nil)
|
||||
}
|
||||
|
||||
if completed == 0 {
|
||||
guard let started = log.startedAt else { return (fallback, nil) }
|
||||
// Clamp to `now` so a peer's clock skew can't stamp a future start and
|
||||
// make the stopwatch (or a timed set's window) run long.
|
||||
return (fallback, workAnchor(page: fallback, start: min(started, now)))
|
||||
}
|
||||
guard let entries = log.setEntries, entries.count >= completed else { return (fallback, nil) }
|
||||
let restStart = min(entries[completed - 1].completedAt, now)
|
||||
let restEnd = restStart.addingTimeInterval(Double(effectiveRest))
|
||||
if now < restEnd {
|
||||
let restPage = base + (completed - 1) * 2 + 1
|
||||
return (restPage, PageAnchor(page: restPage, start: restStart, end: restEnd))
|
||||
}
|
||||
return (fallback, workAnchor(page: fallback, start: restEnd))
|
||||
}
|
||||
|
||||
// MARK: - Live mirror
|
||||
|
||||
/// Finish the initial-page restore, then either follow an in-progress remote driver or, if
|
||||
@@ -546,9 +623,14 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
|
||||
/// Push the current flow position to the watch. The anchor is stamped *now* — the page
|
||||
/// just became active — so the watch's mirror timer lines up with this device's.
|
||||
/// just became active — except when the page carries an anchor (a resumed run continuing
|
||||
/// its phase), which is forwarded so the watch's mirror timer lines up with this device's.
|
||||
private func broadcastLive(for page: Int) {
|
||||
guard let snapshot = liveSnapshot(for: page) else { return }
|
||||
guard var snapshot = liveSnapshot(for: page) else { return }
|
||||
if let anchor = pageAnchor, anchor.page == page {
|
||||
snapshot.phaseStart = anchor.start
|
||||
snapshot.phaseEnd = anchor.end
|
||||
}
|
||||
onLive(snapshot)
|
||||
}
|
||||
|
||||
@@ -670,43 +752,40 @@ struct ExerciseProgressView: View {
|
||||
if autoAdvance, nextUnfinishedLogID != nil {
|
||||
// Flow mode: the terminal page is a between-exercise rest that
|
||||
// completes this exercise and hands off to the next when it ends.
|
||||
VStack(spacing: 0) {
|
||||
CountdownPhaseView(
|
||||
header: "Rest",
|
||||
tint: .restTimer,
|
||||
seconds: effectiveRest,
|
||||
isActive: isActive,
|
||||
anchorStart: anchorStart,
|
||||
anchorEnd: anchorEnd,
|
||||
comingUpName: nextExerciseName,
|
||||
onActivate: announceComingUp,
|
||||
onCountdownBeat: announceCountdownBeat
|
||||
) { _ in
|
||||
completeExercise()
|
||||
// Re-resolve at fire time — the next exercise may have been
|
||||
// finished elsewhere while we rested. None left ⇒ end the run.
|
||||
// No hand-off host (the live-mirror cover) ⇒ close instead of
|
||||
// freezing on a spent countdown; a follower re-presents on the
|
||||
// driver's next-exercise frame.
|
||||
if let next = nextUnfinishedLogID, let onAdvance {
|
||||
onAdvance(next)
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
// The header reads "Coming up" — the headline above already names
|
||||
// the next exercise (it follows `figureExerciseName`).
|
||||
CountdownPhaseView(
|
||||
header: "Coming up",
|
||||
tint: .restTimer,
|
||||
seconds: effectiveRest,
|
||||
isActive: isActive,
|
||||
anchorStart: anchorStart,
|
||||
anchorEnd: anchorEnd,
|
||||
onActivate: announceComingUp,
|
||||
onCountdownBeat: announceCountdownBeat
|
||||
) { _ in
|
||||
completeExercise()
|
||||
// Re-resolve at fire time — the next exercise may have been
|
||||
// finished elsewhere while we rested. None left ⇒ end the run.
|
||||
// No hand-off host (the live-mirror cover) ⇒ close instead of
|
||||
// freezing on a spent countdown; a follower re-presents on the
|
||||
// driver's next-exercise frame.
|
||||
if let next = nextUnfinishedLogID, let onAdvance {
|
||||
onAdvance(next)
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
adjustPill
|
||||
}
|
||||
.overlay(alignment: .bottom) { adjustPill }
|
||||
} else {
|
||||
// Finish page — confirm Done (auto-fires) or add One More.
|
||||
VStack(spacing: 0) {
|
||||
FinishPhaseView(
|
||||
isActive: isActive,
|
||||
onDone: { completeExercise(); dismiss() },
|
||||
onOneMore: addSet,
|
||||
anchorEnd: anchorEnd
|
||||
)
|
||||
adjustPill
|
||||
}
|
||||
FinishPhaseView(
|
||||
isActive: isActive,
|
||||
onDone: { completeExercise(); dismiss() },
|
||||
onOneMore: addSet,
|
||||
anchorEnd: anchorEnd
|
||||
)
|
||||
.overlay(alignment: .bottom) { adjustPill }
|
||||
}
|
||||
} else if cycleIndex.isMultiple(of: 2) {
|
||||
let setNumber = cycleIndex / 2 + 1
|
||||
@@ -736,20 +815,18 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
} else {
|
||||
// Rest phase. Auto-advances to the next work page when the timer hits zero.
|
||||
VStack(spacing: 0) {
|
||||
CountdownPhaseView(
|
||||
header: "Rest",
|
||||
tint: .restTimer,
|
||||
seconds: effectiveRest,
|
||||
isActive: isActive,
|
||||
anchorStart: anchorStart,
|
||||
anchorEnd: anchorEnd,
|
||||
onCountdownBeat: announceCountdownBeat
|
||||
) { end in
|
||||
withAnimation { advance(from: index, phaseEndedAt: end) }
|
||||
}
|
||||
adjustPill
|
||||
CountdownPhaseView(
|
||||
header: "Rest",
|
||||
tint: .restTimer,
|
||||
seconds: effectiveRest,
|
||||
isActive: isActive,
|
||||
anchorStart: anchorStart,
|
||||
anchorEnd: anchorEnd,
|
||||
onCountdownBeat: announceCountdownBeat
|
||||
) { end in
|
||||
withAnimation { advance(from: index, phaseEndedAt: end) }
|
||||
}
|
||||
.overlay(alignment: .bottom) { adjustPill }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1214,41 +1291,26 @@ private extension Color {
|
||||
|
||||
/// Shared skeleton for the work and rest pages so their timers use an identical font
|
||||
/// and land at exactly the same spot: a header line, the big timer, then a footer line.
|
||||
/// The footer reserves its height even when empty, keeping the timer centered the same
|
||||
/// way on both pages.
|
||||
/// The header and footer live in *equal flexible bands* above and below the digits, so
|
||||
/// the timer sits at the exact vertical center of the half on every page — whatever the
|
||||
/// bands hold (or don't) can never nudge it.
|
||||
private struct PhaseTimerLayout<Content: View>: View {
|
||||
let header: String
|
||||
let footer: String
|
||||
let tint: Color
|
||||
/// When set, the header row becomes a two-line "Coming up" / exercise-name preview,
|
||||
/// with the name in larger type — used by the between-exercise rest.
|
||||
var comingUpName: String? = nil
|
||||
@ViewBuilder var timer: Content
|
||||
|
||||
/// Scales the big timer digits with Dynamic Type (falls back to `.minimumScaleFactor`
|
||||
/// so the largest sizes never overflow the top half).
|
||||
@ScaledMetric(relativeTo: .largeTitle) private var timerFontSize: CGFloat = 108
|
||||
/// Scales the "coming up" exercise name — large, but well under the timer.
|
||||
@ScaledMetric(relativeTo: .largeTitle) private var comingUpFontSize: CGFloat = 34
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
if let comingUpName {
|
||||
VStack(spacing: 2) {
|
||||
Text("Coming up")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(comingUpName)
|
||||
.font(.system(size: comingUpFontSize, weight: .bold, design: .rounded))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
} else {
|
||||
Text(header)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
Text(header)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 10)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
|
||||
timer
|
||||
.font(.system(size: timerFontSize, weight: .bold, design: .rounded))
|
||||
@@ -1260,11 +1322,28 @@ private struct PhaseTimerLayout<Content: View>: View {
|
||||
Text(footer.isEmpty ? " " : footer)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 10)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timer Format
|
||||
|
||||
/// Renders the big phase counters — the same "m:ss" (or "h:mm:ss") shape
|
||||
/// `Text(timerInterval:)` used to draw, now built from state-driven whole seconds
|
||||
/// so the digits can roll through `.numericText` transitions.
|
||||
private enum TimerFormat {
|
||||
static func clock(_ seconds: Int) -> String {
|
||||
let s = max(0, seconds)
|
||||
if s >= 3600 {
|
||||
return String(format: "%d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60)
|
||||
}
|
||||
return String(format: "%d:%02d", s / 60, s % 60)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Work Phase Dots
|
||||
|
||||
/// Progress row with one marker per work set. The set being worked is drawn as a wider
|
||||
@@ -1464,25 +1543,38 @@ private struct WorkPhaseView: View {
|
||||
/// wall clock instead of local `now`, so the mirror lines up regardless of delivery lag.
|
||||
var anchorStart: Date? = nil
|
||||
|
||||
/// 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
|
||||
/// keeps it advancing without a run-loop timer.
|
||||
/// Wall-clock anchor for the count-up stopwatch. The displayed seconds are always
|
||||
/// re-derived from this anchor on each tick (never incremented), so a delayed or
|
||||
/// slept-through tick can't drift the clock.
|
||||
@State private var startDate = Date()
|
||||
/// Whole seconds on display, stepped with an animation so the digits roll.
|
||||
@State private var elapsedSeconds = 0
|
||||
private let ticker = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) {
|
||||
Text(startDate, style: .timer)
|
||||
Text(TimerFormat.clock(elapsedSeconds))
|
||||
.contentTransition(.numericText(countsDown: false))
|
||||
}
|
||||
.onAppear { if isActive { restart(haptic: true) } }
|
||||
.onChange(of: isActive) { _, active in if active { restart(haptic: true) } }
|
||||
// A later frame for this same page re-anchors the timer without re-buzzing.
|
||||
.onChange(of: anchorStart) { _, _ in if isActive { restart(haptic: false) } }
|
||||
.onReceive(ticker) { _ in tick() }
|
||||
}
|
||||
|
||||
private func restart(haptic: Bool) {
|
||||
startDate = anchorStart ?? Date()
|
||||
elapsedSeconds = max(0, Int(Date().timeIntervalSince(startDate)))
|
||||
if haptic { WorkoutHaptic.start.play() }
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
guard isActive else { return }
|
||||
let elapsed = max(0, Int(Date().timeIntervalSince(startDate)))
|
||||
guard elapsed != elapsedSeconds else { return }
|
||||
withAnimation(.snappy(duration: 0.25)) { elapsedSeconds = elapsed }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Countdown Phase
|
||||
@@ -1501,8 +1593,6 @@ private struct CountdownPhaseView: View {
|
||||
/// so the remaining time — and the auto-advance at zero — line up across both devices.
|
||||
var anchorStart: Date? = nil
|
||||
var anchorEnd: Date? = nil
|
||||
/// When set, the header becomes a "Coming up / <name>" preview (between-exercise rest).
|
||||
var comingUpName: String? = nil
|
||||
/// Fired once when the countdown genuinely begins (not a slept-through catch-up) — the
|
||||
/// spoken "Coming up …" cue hangs off this.
|
||||
var onActivate: (() -> Void)? = nil
|
||||
@@ -1516,21 +1606,26 @@ private struct CountdownPhaseView: View {
|
||||
/// whenever this tick got runtime (a device asleep at the boundary ticks late).
|
||||
let onFinished: (Date) -> Void
|
||||
|
||||
/// Wall-clock window for the countdown. SwiftUI renders the remaining time from this
|
||||
/// range, and the haptics + auto-advance below are derived from `endDate` rather than
|
||||
/// a decremented counter so they stay correct even if a tick is delayed.
|
||||
/// Wall-clock window for the countdown. The displayed seconds, haptics, and
|
||||
/// auto-advance below are all re-derived from `endDate` on each tick rather than
|
||||
/// decremented, so they stay correct even if a tick is delayed.
|
||||
@State private var startDate = Date()
|
||||
@State private var endDate = Date()
|
||||
/// Whole seconds on display, stepped with an animation so the digits roll.
|
||||
@State private var remainingSeconds = 0
|
||||
/// Lowest remaining-second we've already pinged, so a burst of catch-up ticks doesn't
|
||||
/// double-buzz.
|
||||
@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()
|
||||
/// Sub-second ticks keep the displayed digit, the beats, and the auto-advance all
|
||||
/// within a tenth of the true wall-clock boundary.
|
||||
private let ticker = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
PhaseTimerLayout(header: header, footer: footer, tint: tint, comingUpName: comingUpName) {
|
||||
Text(timerInterval: startDate...endDate, countsDown: true)
|
||||
PhaseTimerLayout(header: header, footer: footer, tint: tint) {
|
||||
Text(TimerFormat.clock(remainingSeconds))
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
}
|
||||
.onAppear { if isActive { start(haptic: true) } }
|
||||
.onChange(of: isActive) { _, active in if active { start(haptic: true) } }
|
||||
@@ -1542,6 +1637,7 @@ private struct CountdownPhaseView: View {
|
||||
private func start(haptic: Bool) {
|
||||
startDate = anchorStart ?? Date()
|
||||
endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds)))
|
||||
remainingSeconds = max(0, Int(endDate.timeIntervalSinceNow))
|
||||
lastPingSecond = Int.max
|
||||
didFinish = false
|
||||
// No buzz — or spoken cue — for a chained catch-up page whose whole window already
|
||||
@@ -1558,6 +1654,9 @@ private struct CountdownPhaseView: View {
|
||||
|
||||
if timeLeft <= 0 {
|
||||
didFinish = true
|
||||
if remainingSeconds != 0 {
|
||||
withAnimation(.snappy(duration: 0.25)) { remainingSeconds = 0 }
|
||||
}
|
||||
// Buzz the boundary only when it just happened — fast-forwarding through
|
||||
// boundaries that passed while the device slept stays silent.
|
||||
if Date().timeIntervalSince(endDate) < 3 {
|
||||
@@ -1567,11 +1666,14 @@ private struct CountdownPhaseView: View {
|
||||
}
|
||||
onFinished(endDate)
|
||||
} else {
|
||||
// Floor, not ceil: `Text(timerInterval:)` displays the floored seconds (it reads
|
||||
// 0:00 through the whole last second), so keying the beats to the same floored
|
||||
// value makes each spoken word name the digit on screen. Beat 0 lands as the
|
||||
// display hits zero, up to a second before the actual phase boundary.
|
||||
// Floor, not ceil: the display reads 0:00 through the whole last second, so
|
||||
// keying the beats to the same floored value makes each spoken word name the
|
||||
// digit on screen. Beat 0 lands as the display hits zero, up to a second
|
||||
// before the actual phase boundary.
|
||||
let displayed = Int(timeLeft)
|
||||
if displayed != remainingSeconds {
|
||||
withAnimation(.snappy(duration: 0.25)) { remainingSeconds = displayed }
|
||||
}
|
||||
if displayed <= 6 && displayed < lastPingSecond {
|
||||
lastPingSecond = displayed
|
||||
if displayed <= 2 { WorkoutHaptic.tick.play() }
|
||||
@@ -1608,6 +1710,7 @@ private struct FinishPhaseView: View {
|
||||
Button(action: fire) {
|
||||
Label(remaining > 0 ? "Done · \(remaining)" : "Done", systemImage: "checkmark")
|
||||
.phaseButtonLabel()
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.workTimer)
|
||||
@@ -1637,7 +1740,9 @@ private struct FinishPhaseView: View {
|
||||
private func tick() {
|
||||
guard isActive, !didFire else { return }
|
||||
let r = Int(ceil(endDate.timeIntervalSinceNow))
|
||||
remaining = max(0, r)
|
||||
if max(0, r) != remaining {
|
||||
withAnimation(.snappy(duration: 0.25)) { remaining = max(0, r) }
|
||||
}
|
||||
if r <= 0 { fire() }
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,11 @@ struct LiveRunCoverView: View {
|
||||
},
|
||||
onActivity: { services.liveActivity.update($0) },
|
||||
onActivityEnded: { services.liveActivity.end() },
|
||||
incomingFrame: incomingFrame
|
||||
incomingFrame: incomingFrame,
|
||||
// The run screen hides the nav bar (and this toolbar's close button
|
||||
// with it); its own header hosts the close action instead.
|
||||
closeSymbol: "xmark",
|
||||
onClose: onClose
|
||||
)
|
||||
} else {
|
||||
// The workout hasn't resolved from the cache yet (or has vanished).
|
||||
|
||||
Reference in New Issue
Block a user