diff --git a/CHANGELOG.md b/CHANGELOG.md index 71dfe59..1452026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index 5eb194e..49b63c1 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -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, 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, 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: 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: 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 / " 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() } } diff --git a/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift b/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift index 2417995..30bd32c 100644 --- a/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift +++ b/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift @@ -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).