Hide the tab bar when the exercise run screen is in landscape

The landscape side-by-side split (paged flow | form-guide figure) is already
starved for height; keying tab-bar visibility off the same compact-vertical
size class that drives the split gives the run the full screen. Applied across
all three body branches (run flow, Completed, Skipped).

Claude-Session: https://claude.ai/code/session_01Y7ZhkCYWNiTSAFhFCGnJ8n
This commit is contained in:
2026-07-11 19:49:55 -04:00
parent b1a5d25b61
commit b06a44eb40
2 changed files with 96 additions and 50 deletions
+2
View File
@@ -1,5 +1,7 @@
**July 2026** **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. 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. Scheduled workouts can now remind you with a notification at a time you choose.
@@ -30,6 +30,7 @@ struct ExerciseProgressView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass @Environment(\.verticalSizeClass) private var verticalSizeClass
/// The shared working workout document owned by the parent list. We mutate the /// 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 /// 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 /// from this doc (not the cache) avoids losing rapid edits to the read-after-write
@@ -313,27 +314,65 @@ struct ExerciseProgressView: View {
: AnyLayout(VStackLayout(spacing: 0)) : AnyLayout(VStackLayout(spacing: 0))
} }
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)
}
} else {
ExerciseFigureSlot(exerciseName: name)
}
}
var body: some View { var body: some View {
Group {
if startsCompleted { if startsCompleted {
// Same half-and-half split as the run flow, with the Completed badge where // 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. // the paged flow would be, so the form-guide figure stays on screen.
splitLayout { splitLayout {
CompletedPhaseView(log: log) CompletedPhaseView(log: log)
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "") figureHalf(log?.exerciseName ?? "")
} }
.navigationTitle(log?.exerciseName ?? "") .navigationTitle(navBarTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} else if startsSkipped { } else if startsSkipped {
splitLayout { splitLayout {
SkippedPhaseView() SkippedPhaseView()
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "") figureHalf(log?.exerciseName ?? "")
} }
.navigationTitle(log?.exerciseName ?? "") .navigationTitle(navBarTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} else { } else {
flowBody 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 { private var flowBody: some View {
splitLayout { splitLayout {
@@ -357,9 +396,9 @@ struct ExerciseProgressView: View {
// resting between exercises (a live preview of what's coming up), otherwise this // 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 // one. The name swap re-loads the figure and carries through the hand-off, so it
// stays put as the next exercise takes over. // stays put as the next exercise takes over.
ExerciseFigureSlot(exerciseName: figureExerciseName) figureHalf(figureExerciseName)
} }
.navigationTitle(log?.exerciseName ?? "") .navigationTitle(navBarTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onChange(of: currentPage) { oldPage, newPage in .onChange(of: currentPage) { oldPage, newPage in
// Ignore page changes until the initial resume settles, so the TabView's // Ignore page changes until the initial resume settles, so the TabView's
@@ -777,43 +816,43 @@ struct ExerciseProgressView: View {
announcer.speak("Coming up. \(next).") announcer.speak("Coming up. \(next).")
} }
/// Count into the next work phase, one word per second synced to the countdown's final /// Count into the next work phase, one word per second synced to the digits the countdown
/// beats: the upcoming exercise's name at 5s left with the 4s beat left silent so even /// displays: the upcoming exercise's name as it shows 6 with the 5s and 4s beats left
/// a long name finishes then "in three" (3s) "two" "one" "Go!" as the timer /// silent so even a long name finishes then "in three" (3) "two" "one" "Go!" as
/// reaches zero. Every beat but "Go" holds the audio session so background music doesn't /// the display reaches zero. Every beat but "Go" holds the audio session so background
/// pulse between them; "Go" releases it, and (like any cue) plays to completion even if /// music doesn't pulse between them; "Go" releases it, and (like any cue) plays to
/// the rest ends mid-word. `remaining` is the whole seconds left (0 at the boundary). /// completion even if the rest ends mid-word. `remaining` is the displayed digit.
private func announceCountdownBeat(_ remaining: Int) { private func announceCountdownBeat(_ remaining: Int) {
guard speaksCues, let announcer = speechAnnouncer else { return } guard speaksCues, let announcer = speechAnnouncer else { return }
switch remaining { switch remaining {
case 5: case 6:
// The exercise about to be worked: the next one across a between-exercise rest, // 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). // otherwise this one. Skipped when unknown (never blocks the count that follows).
let name = figureExerciseName let name = figureExerciseName
if !name.isEmpty { announcer.speak(name + ".", holdSession: true) } 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 3: announcer.speak("In three.", holdSession: true)
case 2: announcer.speak("Two.", holdSession: true) case 2: announcer.speak("Two.", holdSession: true)
case 1: announcer.speak("One.", 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: "<Exercise> ends." at 5s left /// Count a timed work set out, mirroring the count-in: "<Exercise> ends." as the display
/// (4s is its silent buffer), then "in three" "two" "one" and silence at zero, /// shows 6 (5 and 4 are its silent buffer), then "in three" "two" "one" and silence
/// where the rest phase's own buzz marks the boundary. "One" is the last word, so it /// at zero, where the rest phase's own buzz marks the boundary. "One" is the last word,
/// releases the audio duck instead of holding it. /// so it releases the audio duck instead of holding it.
private func announceWorkEndBeat(_ remaining: Int) { private func announceWorkEndBeat(_ remaining: Int) {
guard speaksCues, let announcer = speechAnnouncer else { return } guard speaksCues, let announcer = speechAnnouncer else { return }
switch remaining { switch remaining {
case 5: case 6:
if let name = log?.exerciseName, !name.isEmpty { if let name = log?.exerciseName, !name.isEmpty {
announcer.speak("\(name) ends.", holdSession: true) announcer.speak("\(name) ends.", holdSession: true)
} }
case 3: announcer.speak("In three.", holdSession: true) case 3: announcer.speak("In three.", holdSession: true)
case 2: announcer.speak("Two.", holdSession: true) case 2: announcer.speak("Two.", holdSession: true)
case 1: announcer.speak("One.") case 1: announcer.speak("One.")
default: break // 4 is the name's buffer beat; 0 stays silent default: break // 54 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 /// Fired once when the countdown genuinely begins (not a slept-through catch-up) the
/// spoken "Coming up " cue hangs off this. /// spoken "Coming up " cue hangs off this.
var onActivate: (() -> Void)? = nil var onActivate: (() -> Void)? = nil
/// Fired once per second through the final beats the seconds remaining (5 for the /// Fired once per second through the final beats the digit the countdown display
/// exercise name, 4 as its buffer, then 3, 2, 1), then 0 at the boundary so a spoken /// currently shows (6 for the exercise name, 54 as its buffer, then 3, 2, 1, and 0 as
/// count ("Torso Twist in three two one Go!") lands one word per beat, in step with /// the display reaches zero) so a spoken count ("Torso Twist in three two one
/// the haptics. Nil skips it. /// Go!") names the number on screen, in step with the haptics. Nil skips it.
var onCountdownBeat: ((Int) -> Void)? = nil var onCountdownBeat: ((Int) -> Void)? = nil
/// Invoked once the countdown reaches zero (auto-advance to the next page), passing the /// 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 /// 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() { private func tick() {
guard isActive, !didFinish else { return } guard isActive, !didFinish else { return }
// Round up so the final whole second still pings before we reach zero. let timeLeft = endDate.timeIntervalSinceNow
let remaining = Int(ceil(endDate.timeIntervalSinceNow))
if remaining <= 0 { if timeLeft <= 0 {
didFinish = true didFinish = true
// Buzz and speak the final "Go" only for a boundary that just happened // Buzz the boundary only when it just happened fast-forwarding through
// fast-forwarding through boundaries that passed while the device slept stays silent. // boundaries that passed while the device slept stays silent.
if Date().timeIntervalSince(endDate) < 3 { if Date().timeIntervalSince(endDate) < 3 {
WorkoutHaptic.stop.play() 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) onFinished(endDate)
} else if remaining <= 5 && remaining < lastPingSecond { } else {
// One spoken beat per second through the final five: the exercise name at 5s, // Floor, not ceil: `Text(timerInterval:)` displays the floored seconds (it reads
// a buffer, then the count. The haptic ping stays on the final three seconds only. // 0:00 through the whole last second), so keying the beats to the same floored
lastPingSecond = remaining // value makes each spoken word name the digit on screen. Beat 0 lands as the
if remaining <= 3 { WorkoutHaptic.tick.play() } // display hits zero, up to a second before the actual phase boundary.
onCountdownBeat?(remaining) let displayed = Int(timeLeft)
if displayed <= 6 && displayed < lastPingSecond {
lastPingSecond = displayed
if displayed <= 2 { WorkoutHaptic.tick.play() }
onCountdownBeat?(displayed)
}
} }
} }
} }