Show both identities in a renamed exercise's headline and repair legacy figures

The run-screen headline now reads "Library · Name" for a renamed exercise,
keeping its origin visible next to the custom name (the separate subtitle
line is gone). Logs minted before the per-log libraryName snapshot existed
carry none, which cost a renamed exercise its figure, guide, and spoken
cues — the screen now resolves the library identity through the workout's
routine (same-named exercise, display-only) as a fallback, so those older
workouts get their animated figure back, including on the Completed page.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
This commit is contained in:
2026-07-14 21:30:00 -04:00
parent e8b1cb800d
commit 4cd6e4181c
2 changed files with 49 additions and 25 deletions
+4
View File
@@ -1,5 +1,9 @@
**July 2026**
A renamed exercise's headline now shows both identities on one line, like "Cardio · Warm Up".
Renamed exercises in workouts recorded by older versions get their animated figure and guide back.
The exercise screen now shows the exercise name in large type with a simple back chevron in place of the toolbar, in portrait and landscape alike.
Work and rest counters now sit at the exact same spot on screen, so the digits no longer shift between phases.
@@ -29,6 +29,8 @@ import UIKit
struct ExerciseProgressView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
@Environment(\.modelContext) private var modelContext
@Environment(SyncEngine.self) private var sync
/// Live watch-forwarded heart rate (see `LiveRunState.heartRate`) drives the
/// target-HR pill when this run carries a `targetHeartRate`.
@@ -275,8 +277,31 @@ struct ExerciseProgressView: View {
/// this-exercise-or-next-during-rest choice as `figureExerciseName`, but the
/// library name the animation/info actually key on.
private var figureLibraryExerciseName: String {
if isOnBetweenExerciseRest, let next = nextLog { return next.libraryExerciseName }
return log?.libraryExerciseName ?? ""
if isOnBetweenExerciseRest, let next = nextLog { return libraryIdentity(next) }
return libraryIdentity(log)
}
/// Routine exercise name library identity, resolved once on appear. Display-only
/// fallback for logs minted before the per-log `libraryName` snapshot existed
/// without it a renamed exercise from an older workout loses its figure and guide.
@State private var routineLibraryNames: [String: String] = [:]
/// A log's library identity: its own snapshot when present, else the routine's
/// same-named exercise (legacy logs), else the exercise name itself.
private func libraryIdentity(_ log: WorkoutLogDocument?) -> String {
guard let log else { return "" }
return log.libraryName ?? routineLibraryNames[log.exerciseName] ?? log.exerciseName
}
/// Fill `routineLibraryNames` from the workout's routine (following the seed
/// clone-on-edit redirect), so `libraryIdentity` can repair legacy logs.
private func resolveRoutineLibraryNames() {
guard let routineID = doc.routineID,
let routine = CacheMapper.fetchRoutine(id: sync.currentRoutineID(for: routineID),
in: modelContext) else { return }
routineLibraryNames = Dictionary(
routine.exercisesArray.map { ($0.name, $0.libraryExerciseName) },
uniquingKeysWith: { first, _ in first })
}
/// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
@@ -359,12 +384,13 @@ struct ExerciseProgressView: View {
/// Scales the header exercise-name headline with Dynamic Type.
@ScaledMetric(relativeTo: .largeTitle) private var headerNameFontSize: CGFloat = 30
/// The library name shown under the headline only once an exercise was renamed,
/// so the form-guide's identity stays visible next to the custom name.
private var headerLibrarySubtitle: String? {
/// Headline for the header bar: "Library · Name" once an exercise was renamed
/// both identities on one line else just the name.
private var headerTitle: String {
let name = figureExerciseName
let library = figureLibraryExerciseName
guard !library.isEmpty, library != figureExerciseName else { return nil }
return library
guard !library.isEmpty, library != name else { return name }
return "\(library) · \(name)"
}
/// Replaces the navigation bar in both orientations: the back chevron top-left and
@@ -373,21 +399,12 @@ struct ExerciseProgressView: View {
/// figure below.
private var headerBar: some View {
ZStack {
VStack(spacing: 0) {
Text(figureExerciseName)
.font(.system(size: headerNameFontSize, weight: .bold, design: .rounded))
.lineLimit(1)
.minimumScaleFactor(0.5)
if let headerLibrarySubtitle {
Text(headerLibrarySubtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
}
.padding(.horizontal, 52) // stay clear of the chevron, centered on screen
.frame(maxWidth: .infinity)
Text(headerTitle)
.font(.system(size: headerNameFontSize, weight: .bold, design: .rounded))
.lineLimit(1)
.minimumScaleFactor(0.4)
.padding(.horizontal, 52) // stay clear of the chevron, centered on screen
.frame(maxWidth: .infinity)
HStack {
Button {
@@ -412,17 +429,20 @@ struct ExerciseProgressView: View {
// the paged flow would be, so the form-guide figure stays on screen.
splitLayout {
CompletedPhaseView(log: log)
ExerciseFigureSlot(exerciseName: log?.libraryExerciseName ?? "")
ExerciseFigureSlot(exerciseName: libraryIdentity(log))
}
} else if startsSkipped {
splitLayout {
SkippedPhaseView()
ExerciseFigureSlot(exerciseName: log?.libraryExerciseName ?? "")
ExerciseFigureSlot(exerciseName: libraryIdentity(log))
}
} else {
flowBody
}
}
// Repair legacy logs' library identity from the routine before anything keys
// off it (figure, headline, spoken cues).
.onAppear(perform: resolveRoutineLibraryNames)
// The flow owns its chrome: the nav bar is hidden in both orientations (the
// header above carries the name and back chevron in its place).
.toolbar(.hidden, for: .navigationBar)
@@ -932,7 +952,7 @@ struct ExerciseProgressView: View {
private func announceCue() {
guard speakExerciseCues, spokenCueStyle.readsInstructions,
let announcer = speechAnnouncer, let log else { return }
guard let info = ExerciseInfoLibrary.info(for: log.libraryExerciseName) else { return }
guard let info = ExerciseInfoLibrary.info(for: libraryIdentity(log)) else { return }
announcer.speak(info.spokenScript(name: log.exerciseName, brief: true))
}