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
@@ -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))
}