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**
|
**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.
|
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.
|
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 {
|
struct ExerciseProgressView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
@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
|
/// Live watch-forwarded heart rate (see `LiveRunState.heartRate`) — drives the
|
||||||
/// target-HR pill when this run carries a `targetHeartRate`.
|
/// 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
|
/// this-exercise-or-next-during-rest choice as `figureExerciseName`, but the
|
||||||
/// library name the animation/info actually key on.
|
/// library name the animation/info actually key on.
|
||||||
private var figureLibraryExerciseName: String {
|
private var figureLibraryExerciseName: String {
|
||||||
if isOnBetweenExerciseRest, let next = nextLog { return next.libraryExerciseName }
|
if isOnBetweenExerciseRest, let next = nextLog { return libraryIdentity(next) }
|
||||||
return log?.libraryExerciseName ?? ""
|
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`.
|
/// 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.
|
/// Scales the header exercise-name headline with Dynamic Type.
|
||||||
@ScaledMetric(relativeTo: .largeTitle) private var headerNameFontSize: CGFloat = 30
|
@ScaledMetric(relativeTo: .largeTitle) private var headerNameFontSize: CGFloat = 30
|
||||||
|
|
||||||
/// The library name shown under the headline — only once an exercise was renamed,
|
/// Headline for the header bar: "Library · Name" once an exercise was renamed —
|
||||||
/// so the form-guide's identity stays visible next to the custom name.
|
/// both identities on one line — else just the name.
|
||||||
private var headerLibrarySubtitle: String? {
|
private var headerTitle: String {
|
||||||
|
let name = figureExerciseName
|
||||||
let library = figureLibraryExerciseName
|
let library = figureLibraryExerciseName
|
||||||
guard !library.isEmpty, library != figureExerciseName else { return nil }
|
guard !library.isEmpty, library != name else { return name }
|
||||||
return library
|
return "\(library) · \(name)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the navigation bar in both orientations: the back chevron top-left and
|
/// Replaces the navigation bar in both orientations: the back chevron top-left and
|
||||||
@@ -373,19 +399,10 @@ struct ExerciseProgressView: View {
|
|||||||
/// figure below.
|
/// figure below.
|
||||||
private var headerBar: some View {
|
private var headerBar: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
VStack(spacing: 0) {
|
Text(headerTitle)
|
||||||
Text(figureExerciseName)
|
|
||||||
.font(.system(size: headerNameFontSize, weight: .bold, design: .rounded))
|
.font(.system(size: headerNameFontSize, weight: .bold, design: .rounded))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.5)
|
.minimumScaleFactor(0.4)
|
||||||
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
|
.padding(.horizontal, 52) // stay clear of the chevron, centered on screen
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
@@ -412,17 +429,20 @@ struct ExerciseProgressView: View {
|
|||||||
// the paged flow would be, so the form-guide figure stays on screen.
|
// the paged flow would be, so the form-guide figure stays on screen.
|
||||||
splitLayout {
|
splitLayout {
|
||||||
CompletedPhaseView(log: log)
|
CompletedPhaseView(log: log)
|
||||||
ExerciseFigureSlot(exerciseName: log?.libraryExerciseName ?? "")
|
ExerciseFigureSlot(exerciseName: libraryIdentity(log))
|
||||||
}
|
}
|
||||||
} else if startsSkipped {
|
} else if startsSkipped {
|
||||||
splitLayout {
|
splitLayout {
|
||||||
SkippedPhaseView()
|
SkippedPhaseView()
|
||||||
ExerciseFigureSlot(exerciseName: log?.libraryExerciseName ?? "")
|
ExerciseFigureSlot(exerciseName: libraryIdentity(log))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
flowBody
|
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
|
// 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).
|
// header above carries the name and back chevron in its place).
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
@@ -932,7 +952,7 @@ struct ExerciseProgressView: View {
|
|||||||
private func announceCue() {
|
private func announceCue() {
|
||||||
guard speakExerciseCues, spokenCueStyle.readsInstructions,
|
guard speakExerciseCues, spokenCueStyle.readsInstructions,
|
||||||
let announcer = speechAnnouncer, let log else { return }
|
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))
|
announcer.speak(info.spokenScript(name: log.exerciseName, brief: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user