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:
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user