Preview the next exercise and count into each set during rests
The run flow's between-exercise rest now previews the next exercise — its
figure and a large "Coming up" name — and the hands-free narration gains a
per-second count-in ("<Exercise>, in 1, 2, 3, GO!") spoken before every set,
plus a "Coming up" announcement as a between-exercise rest begins. A new
Settings > Narration picker chooses the count-in cues, the setup/form read,
or both.
Also fixes spoken cues going silent after the first exercise in a flow split:
the stop-on-teardown moved from the per-exercise view (rebuilt on every
hand-off) to the run host, which stays mounted for the whole run. The audio
session now holds its duck across the per-second count so background music
doesn't pulse between words.
Claude-Session: https://claude.ai/code/session_01BQcEWmAPA78338QuEwRkAh
This commit is contained in:
@@ -83,6 +83,10 @@ struct ExerciseProgressView: View {
|
||||
/// always available.
|
||||
@AppStorage("speakExerciseCues") private var speakExerciseCues = false
|
||||
|
||||
/// What the automatic narration says (only when `speakExerciseCues` is on): the concise
|
||||
/// coming-up/countdown cues, the setup & form read, or both.
|
||||
@AppStorage("spokenCueStyle") private var spokenCueStyle: SpokenCueStyle = .cues
|
||||
|
||||
/// Auto-Done countdown on the Finish page — read so a broadcast frame carries the same
|
||||
/// end anchor the watch's mirror counts off.
|
||||
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||
@@ -211,6 +215,27 @@ struct ExerciseProgressView: View {
|
||||
}?.id
|
||||
}
|
||||
|
||||
/// Name of the next not-yet-resolved exercise (the flow hand-off target), or nil when
|
||||
/// this is the last one. Drives the between-exercise rest's "Coming up …" preview.
|
||||
private var nextExerciseName: String? {
|
||||
guard let id = nextUnfinishedLogID else { return nil }
|
||||
return doc.logs.first { $0.id == id }?.exerciseName
|
||||
}
|
||||
|
||||
/// The between-exercise rest — flow mode's terminal page when another exercise follows.
|
||||
/// This is the only rest whose "next" is a *different* exercise, so it's the one that
|
||||
/// previews the next figure and speaks "Coming up …".
|
||||
private var isOnBetweenExerciseRest: Bool {
|
||||
autoAdvance && nextUnfinishedLogID != nil && currentPage == base + cycleCount
|
||||
}
|
||||
|
||||
/// Which exercise the bottom-half figure should show: the *next* one while resting
|
||||
/// between exercises (so you see what's coming up), otherwise this exercise.
|
||||
private var figureExerciseName: String {
|
||||
if isOnBetweenExerciseRest, let next = nextExerciseName { return next }
|
||||
return log?.exerciseName ?? ""
|
||||
}
|
||||
|
||||
/// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
|
||||
private var base: Int { showsReady ? 1 : 0 }
|
||||
|
||||
@@ -328,9 +353,11 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom half: the looping form-guide figure when a bundled motion
|
||||
// matches this exercise; empty space otherwise.
|
||||
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
|
||||
// Bottom half: the looping form-guide figure. Shows the *next* exercise while
|
||||
// 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)
|
||||
}
|
||||
.navigationTitle(log?.exerciseName ?? "")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -409,10 +436,12 @@ struct ExerciseProgressView: View {
|
||||
.onDisappear {
|
||||
// Leaving the flow (back / done) — stop the watch from following and dismiss the
|
||||
// Live Activity (it lingers briefly on a completed run before auto-dismissing).
|
||||
// Speech is *not* stopped here: this view is torn down and rebuilt on every
|
||||
// flow hand-off (`.id(currentLogID)`), so stopping would cut the incoming
|
||||
// exercise's cue. The run host (`RunFlowView`) silences narration when the
|
||||
// whole run leaves instead.
|
||||
onLiveEnded()
|
||||
onActivityEnded()
|
||||
// Don't keep narrating a screen the user has left.
|
||||
speechAnnouncer?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,7 +615,10 @@ struct ExerciseProgressView: View {
|
||||
seconds: effectiveRest,
|
||||
isActive: isActive,
|
||||
anchorStart: anchorStart,
|
||||
anchorEnd: anchorEnd
|
||||
anchorEnd: anchorEnd,
|
||||
comingUpName: nextExerciseName,
|
||||
onActivate: announceComingUp,
|
||||
onCountdownBeat: announceCountdownBeat
|
||||
) { _ in
|
||||
completeExercise()
|
||||
// Re-resolve at fire time — the next exercise may have been
|
||||
@@ -648,7 +680,8 @@ struct ExerciseProgressView: View {
|
||||
seconds: effectiveRest,
|
||||
isActive: isActive,
|
||||
anchorStart: anchorStart,
|
||||
anchorEnd: anchorEnd
|
||||
anchorEnd: anchorEnd,
|
||||
onCountdownBeat: announceCountdownBeat
|
||||
) { end in
|
||||
withAnimation { advance(from: index, phaseEndedAt: end) }
|
||||
}
|
||||
@@ -720,14 +753,51 @@ struct ExerciseProgressView: View {
|
||||
withAnimation { currentPage = base }
|
||||
}
|
||||
|
||||
/// Speak the exercise's setup/form cues when it starts, if the user opted in. Silent
|
||||
/// when the announcer isn't wired (screenshot host) or the exercise has no bundled info.
|
||||
/// True when narration is on and the chosen style speaks the concise coming-up/countdown
|
||||
/// cues. Also requires a wired announcer (nil in the screenshot host).
|
||||
private var speaksCues: Bool {
|
||||
speakExerciseCues && spokenCueStyle.speaksCues && speechAnnouncer != nil
|
||||
}
|
||||
|
||||
/// Speak the exercise's setup/form cues when it starts, if the user opted into a style
|
||||
/// that reads them. Silent when the announcer isn't wired (screenshot host) or the
|
||||
/// exercise has no bundled info.
|
||||
private func announceCue() {
|
||||
guard speakExerciseCues, let announcer = speechAnnouncer, let log else { return }
|
||||
guard speakExerciseCues, spokenCueStyle.readsInstructions,
|
||||
let announcer = speechAnnouncer, let log else { return }
|
||||
guard let info = ExerciseInfoLibrary.info(for: log.exerciseName) else { return }
|
||||
announcer.speak(info.spokenScript(name: log.exerciseName, brief: true))
|
||||
}
|
||||
|
||||
/// Announce the exercise coming up after a between-exercise rest ("Coming up, Pull Ups").
|
||||
/// Spoken as the rest begins, so it lands well before the next exercise starts.
|
||||
private func announceComingUp() {
|
||||
guard speaksCues, let announcer = speechAnnouncer, let next = nextExerciseName else { return }
|
||||
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 (4s left) → "in one" (3s) → "two" → "three" →
|
||||
/// "Go!" as the timer reaches zero — e.g. "Torso Twist … in one, two, three, GO". The
|
||||
/// name gets its own beat so even a long one is heard before the count. 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).
|
||||
private func announceCountdownBeat(_ remaining: Int) {
|
||||
guard speaksCues, let announcer = speechAnnouncer else { return }
|
||||
switch remaining {
|
||||
case 4:
|
||||
// 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 3: announcer.speak("In one.", holdSession: true)
|
||||
case 2: announcer.speak("Two.", holdSession: true)
|
||||
case 1: announcer.speak("Three.", holdSession: true)
|
||||
default: announcer.speak("Go!") // the boundary — release the duck for the work phase
|
||||
}
|
||||
}
|
||||
|
||||
/// Programmatically move one page right when a countdown phase ends, guarding against
|
||||
/// overrun if the user swiped away in the meantime. Tagged `.auto` so the page observer
|
||||
/// records progress but doesn't broadcast it (the watch auto-advances too).
|
||||
@@ -1021,17 +1091,35 @@ private struct PhaseTimerLayout<Content: View>: 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) {
|
||||
Text(header)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
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)
|
||||
}
|
||||
|
||||
timer
|
||||
.font(.system(size: timerFontSize, weight: .bold, design: .rounded))
|
||||
@@ -1284,6 +1372,15 @@ 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 / <name>" 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
|
||||
/// Fired once per second through the count-in — the seconds remaining (4 for the exercise
|
||||
/// name, then 3, 2, 1), then 0 at the boundary — so a spoken count ("Torso Twist… in one…
|
||||
/// two… three… Go!") lands one word per beat, 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
|
||||
/// whenever this tick got runtime (a device asleep at the boundary ticks late).
|
||||
@@ -1302,7 +1399,7 @@ private struct CountdownPhaseView: View {
|
||||
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
PhaseTimerLayout(header: header, footer: footer, tint: tint) {
|
||||
PhaseTimerLayout(header: header, footer: footer, tint: tint, comingUpName: comingUpName) {
|
||||
Text(timerInterval: startDate...endDate, countsDown: true)
|
||||
}
|
||||
.onAppear { if isActive { start(haptic: true) } }
|
||||
@@ -1317,9 +1414,12 @@ private struct CountdownPhaseView: View {
|
||||
endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds)))
|
||||
lastPingSecond = Int.max
|
||||
didFinish = false
|
||||
// No buzz for a chained catch-up page whose whole window already elapsed while
|
||||
// the device slept — it advances again on its next tick.
|
||||
if haptic, endDate > Date() { WorkoutHaptic.start.play() }
|
||||
// No buzz — or spoken cue — for a chained catch-up page whose whole window already
|
||||
// elapsed while the device slept; it advances again on its next tick.
|
||||
if haptic, endDate > Date() {
|
||||
WorkoutHaptic.start.play()
|
||||
onActivate?()
|
||||
}
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
@@ -1329,14 +1429,19 @@ private struct CountdownPhaseView: View {
|
||||
|
||||
if remaining <= 0 {
|
||||
didFinish = true
|
||||
// Buzz only for a boundary that just happened — fast-forwarding through
|
||||
// boundaries that passed while the device slept stays silent.
|
||||
if Date().timeIntervalSince(endDate) < 3 { WorkoutHaptic.stop.play() }
|
||||
// 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.
|
||||
if Date().timeIntervalSince(endDate) < 3 {
|
||||
WorkoutHaptic.stop.play()
|
||||
onCountdownBeat?(0)
|
||||
}
|
||||
onFinished(endDate)
|
||||
} else if remaining <= 3 && remaining < lastPingSecond {
|
||||
// Once-per-second countdown ping for the final three seconds.
|
||||
} else if remaining <= 4 && remaining < lastPingSecond {
|
||||
// One spoken beat per second through the count-in: the exercise name at 4s, then
|
||||
// the count. The haptic ping stays on the final three seconds only.
|
||||
lastPingSecond = remaining
|
||||
WorkoutHaptic.tick.play()
|
||||
if remaining <= 3 { WorkoutHaptic.tick.play() }
|
||||
onCountdownBeat?(remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user