From 4cd6e4181cb43e7d9db8a6fac7ae4e5ef010323a Mon Sep 17 00:00:00 2001 From: rzen Date: Tue, 14 Jul 2026 21:30:00 -0400 Subject: [PATCH] Show both identities in a renamed exercise's headline and repair legacy figures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 4 ++ .../WorkoutLogs/ExerciseProgressView.swift | 70 ++++++++++++------- 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1452026..da246f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index 49b63c1..5b5de5c 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -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)) }