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:
2026-07-10 09:38:45 -04:00
parent bce2bf7539
commit 8b20d7ad08
7 changed files with 208 additions and 35 deletions
+10 -1
View File
@@ -22,6 +22,7 @@ struct SettingsView: View {
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
@AppStorage("figureProfile") private var figureProfile: FigureProfile = .neutral
@AppStorage("speakExerciseCues") private var speakExerciseCues = false
@AppStorage("spokenCueStyle") private var spokenCueStyle: SpokenCueStyle = .cues
@AppStorage(SpeechSettings.voiceIdentifierKey) private var speechVoiceIdentifier = ""
@State private var isRestoringSeeds = false
@@ -222,6 +223,14 @@ struct SettingsView: View {
Toggle("Speak Exercise Cues", isOn: $speakExerciseCues)
if speakExerciseCues {
Picker("Narration", selection: $spokenCueStyle) {
ForEach(SpokenCueStyle.allCases) { style in
Text(style.displayName).tag(style)
}
}
}
Picker("Voice", selection: $speechVoiceIdentifier) {
Text("Automatic").tag("")
ForEach(installedVoices, id: \.identifier) { voice in
@@ -257,7 +266,7 @@ struct SettingsView: View {
} header: {
Text("Voice")
} footer: {
Text("Speak Exercise Cues reads each exercise's setup and form cues aloud when you start it; tap a library exercise's speaker to hear its full instructions anytime.")
Text("Speak Exercise Cues narrates a running workout hands-free. Coming Up & Countdown announces the next exercise during a rest and counts “in 1, 2, 3, GO!” before each set; Setup & Form Read reads each exercise's cues aloud when it starts. Tap a library exercise's speaker to hear its full instructions anytime.")
}
}
@@ -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)
}
}
}
+7 -1
View File
@@ -72,7 +72,13 @@ struct RunFlowView: View {
.id(currentLogID)
}
.onAppear { liveRun.navigatedRunID = currentLogID }
.onDisappear { if liveRun.navigatedRunID == currentLogID { liveRun.navigatedRunID = nil } }
.onDisappear {
if liveRun.navigatedRunID == currentLogID { liveRun.navigatedRunID = nil }
// Silence narration only when the *whole run* leaves. This host stays mounted
// across per-exercise hand-offs (the child re-ids), so stopping here rather
// than in the child's onDisappear no longer cuts off the next exercise's cue.
speechAnnouncer?.stop()
}
}
/// Hand off to the next exercise. Suppresses the leaving exercise's live-ended/activity