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:
@@ -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 // 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user