diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b6623e..5cc2537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **July 2026** +The exercise screen now uses the full display in landscape by hiding the tab bar. + Tap any achievement to open a detail page explaining how to earn it and why it matters. Scheduled workouts can now remind you with a notification at a time you choose. diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index bef113f..bd07365 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -30,6 +30,7 @@ struct ExerciseProgressView: View { @Environment(\.dismiss) private var dismiss @Environment(\.verticalSizeClass) private var verticalSizeClass + /// The shared working workout document owned by the parent list. We mutate the /// matching log in place and ask the parent to persist each change — driving the UI /// from this doc (not the cache) avoids losing rapid edits to the read-after-write @@ -313,28 +314,66 @@ struct ExerciseProgressView: View { : AnyLayout(VStackLayout(spacing: 0)) } - var body: some View { - 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) - ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "") + private var isLandscape: Bool { verticalSizeClass == .compact } + + /// 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 figure half of the split, with the big-type exercise headline above the + /// figure in landscape. + @ViewBuilder + private func figureHalf(_ name: String) -> some View { + if isLandscape { + VStack(spacing: 0) { + Text(name) + .font(.system(size: landscapeNameFontSize, weight: .bold, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.5) + .padding(.horizontal) + .padding(.top, 8) + ExerciseFigureSlot(exerciseName: name) } - .navigationTitle(log?.exerciseName ?? "") - .navigationBarTitleDisplayMode(.inline) - } else if startsSkipped { - splitLayout { - SkippedPhaseView() - ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "") - } - .navigationTitle(log?.exerciseName ?? "") - .navigationBarTitleDisplayMode(.inline) } else { - flowBody + ExerciseFigureSlot(exerciseName: name) } } + var body: some View { + Group { + 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(log?.exerciseName ?? "") + } + .navigationTitle(navBarTitle) + .navigationBarTitleDisplayMode(.inline) + } else if startsSkipped { + splitLayout { + SkippedPhaseView() + figureHalf(log?.exerciseName ?? "") + } + .navigationTitle(navBarTitle) + .navigationBarTitleDisplayMode(.inline) + } else { + flowBody + } + } + // 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) + } + private var flowBody: some View { splitLayout { // Paged flow — top half. @@ -357,9 +396,9 @@ 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. - ExerciseFigureSlot(exerciseName: figureExerciseName) + figureHalf(figureExerciseName) } - .navigationTitle(log?.exerciseName ?? "") + .navigationTitle(navBarTitle) .navigationBarTitleDisplayMode(.inline) .onChange(of: currentPage) { oldPage, newPage in // Ignore page changes until the initial resume settles, so the TabView's @@ -777,43 +816,43 @@ struct ExerciseProgressView: View { announcer.speak("Coming up. \(next).") } - /// Count into the next work phase, one word per second synced to the countdown's final - /// beats: the upcoming exercise's name at 5s left — with the 4s beat left silent so even - /// a long name finishes — then "in three" (3s) → "two" → "one" → "Go!" as the timer - /// reaches zero. Every beat but "Go" holds the audio session so background music doesn't - /// pulse between them; "Go" releases it, and (like any cue) plays to completion even if - /// the rest ends mid-word. `remaining` is the whole seconds left (0 at the boundary). + /// Count into the next work phase, one word per second synced to the digits the countdown + /// displays: the upcoming exercise's name as it shows 6 — with the 5s and 4s beats left + /// silent so even a long name finishes — then "in three" (3) → "two" → "one" → "Go!" as + /// the display reaches zero. Every beat but "Go" holds the audio session so background + /// music doesn't pulse between them; "Go" releases it, and (like any cue) plays to + /// completion even if the rest ends mid-word. `remaining` is the displayed digit. private func announceCountdownBeat(_ remaining: Int) { guard speaksCues, let announcer = speechAnnouncer else { return } switch remaining { - case 5: + case 6: // The exercise about to be worked: the next one across a between-exercise rest, // otherwise this one. Skipped when unknown (never blocks the count that follows). let name = figureExerciseName if !name.isEmpty { announcer.speak(name + ".", holdSession: true) } - case 4: break // buffer beat — lets the exercise name play out before the count + case 5, 4: break // buffer beats — let the exercise name play out before the count case 3: announcer.speak("In three.", holdSession: true) case 2: announcer.speak("Two.", holdSession: true) case 1: announcer.speak("One.", holdSession: true) - default: announcer.speak("Go!") // the boundary — release the duck for the work phase + default: announcer.speak("Go!") // display at zero — release the duck for the work phase } } - /// Count a timed work set out, mirroring the count-in: " ends." at 5s left - /// (4s is its silent buffer), then "in three" → "two" → "one" — and silence at zero, - /// where the rest phase's own buzz marks the boundary. "One" is the last word, so it - /// releases the audio duck instead of holding it. + /// Count a timed work set out, mirroring the count-in: " ends." as the display + /// shows 6 (5 and 4 are its silent buffer), then "in three" → "two" → "one" — and silence + /// at zero, where the rest phase's own buzz marks the boundary. "One" is the last word, + /// so it releases the audio duck instead of holding it. private func announceWorkEndBeat(_ remaining: Int) { guard speaksCues, let announcer = speechAnnouncer else { return } switch remaining { - case 5: + case 6: if let name = log?.exerciseName, !name.isEmpty { announcer.speak("\(name) ends.", holdSession: true) } case 3: announcer.speak("In three.", holdSession: true) case 2: announcer.speak("Two.", holdSession: true) case 1: announcer.speak("One.") - default: break // 4 is the name's buffer beat; 0 stays silent + default: break // 5–4 are the name's buffer beats; 0 stays silent } } @@ -1396,10 +1435,10 @@ private struct CountdownPhaseView: View { /// 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 - /// Fired once per second through the final beats — the seconds remaining (5 for the - /// exercise name, 4 as its buffer, then 3, 2, 1), then 0 at the boundary — so a spoken - /// count ("Torso Twist… in three… two… one… Go!") lands one word per beat, in step with - /// the haptics. Nil skips it. + /// Fired once per second through the final beats — the digit the countdown display + /// currently shows (6 for the exercise name, 5–4 as its buffer, then 3, 2, 1, and 0 as + /// the display reaches zero) — so a spoken count ("Torso Twist… in three… two… one… + /// Go!") names the number on screen, in step with the haptics. Nil skips it. var onCountdownBeat: ((Int) -> Void)? = nil /// Invoked once the countdown reaches zero (auto-advance to the next page), passing the /// phase's *computed* end so the next phase can anchor at the boundary itself — not at @@ -1444,24 +1483,29 @@ private struct CountdownPhaseView: View { private func tick() { guard isActive, !didFinish else { return } - // Round up so the final whole second still pings before we reach zero. - let remaining = Int(ceil(endDate.timeIntervalSinceNow)) + let timeLeft = endDate.timeIntervalSinceNow - if remaining <= 0 { + if timeLeft <= 0 { didFinish = true - // Buzz and speak the final "Go" only for a boundary that just happened — - // fast-forwarding through boundaries that passed while the device slept stays silent. + // 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 { WorkoutHaptic.stop.play() - onCountdownBeat?(0) + // A missed final tick means the 0 beat ("Go!") never fired — deliver it here. + if lastPingSecond > 0 { onCountdownBeat?(0) } } onFinished(endDate) - } else if remaining <= 5 && remaining < lastPingSecond { - // One spoken beat per second through the final five: the exercise name at 5s, - // a buffer, then the count. The haptic ping stays on the final three seconds only. - lastPingSecond = remaining - if remaining <= 3 { WorkoutHaptic.tick.play() } - onCountdownBeat?(remaining) + } 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. + let displayed = Int(timeLeft) + if displayed <= 6 && displayed < lastPingSecond { + lastPingSecond = displayed + if displayed <= 2 { WorkoutHaptic.tick.play() } + onCountdownBeat?(displayed) + } } } }