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**
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.
@@ -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: "<Exercise> 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: "<Exercise> 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 // 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
/// 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, 54 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)
}
}
}
}