Redesign the workout exercise row layout

Weight moves up beside the exercise name (matching font), sets × reps
sits below it with a dimmed ×, and recorded machine settings show as a
secondary line under the name — no placeholder when unconfigured.
Single-line bodyweight rows center against the checkbox.

Claude-Session: https://claude.ai/code/session_01P152LxjZ4vePHsSorQa5Jf
This commit is contained in:
2026-07-07 18:06:11 -04:00
parent df6da35aa6
commit 29f04a878a
2 changed files with 55 additions and 39 deletions
@@ -372,10 +372,10 @@ struct WorkoutLogListView: View {
// MARK: - Workout Log Row
/// One exercise line item: the status checkbox, the exercise name with a
/// prominent sets × reps line under it, and a trailing column for the load.
/// Weight only appears for weighted exercises a bodyweight exercise
/// (`LoadType.none`) shows no load at all.
/// One exercise line item: the status checkbox, the exercise name with the
/// machine settings line under it, and a trailing column with the load on top
/// and sets × reps below. Weight only appears for weighted exercises a
/// bodyweight exercise (`LoadType.none`) shows no load at all.
private struct WorkoutLogRow: View {
let status: CheckboxStatus
let log: WorkoutLogDocument
@@ -385,21 +385,25 @@ private struct WorkoutLogRow: View {
private var loadType: LoadType { LoadType(rawValue: log.loadType) ?? .none }
/// Prominent "3 × 12" (or "3 × 5 min" for timed exercises).
private var setsAndReps: String {
/// "3 × 12" (or "3 × 5 min" for timed exercises) with the numbers in full
/// strength and the × dimmed, so the counts read at a glance.
private var setsAndReps: Text {
let times = Text(" × ").foregroundStyle(.secondary)
let count: String
if loadType == .duration {
let mins = log.durationSeconds / 60
let secs = log.durationSeconds % 60
if mins > 0 && secs > 0 {
return "\(log.sets) × \(mins)m \(secs)s"
count = "\(mins)m \(secs)s"
} else if mins > 0 {
return "\(log.sets) × \(mins) min"
count = "\(mins) min"
} else {
return "\(log.sets) × \(secs) sec"
count = "\(secs) sec"
}
} else {
return "\(log.sets) × \(log.reps)"
count = "\(log.reps)"
}
return Text("\(log.sets)") + times + Text(count)
}
private var showsWeight: Bool { loadType == .weight }
@@ -415,8 +419,16 @@ private struct WorkoutLogRow: View {
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
/// Top-align when either column stacks two lines (weight over sets × reps,
/// or name over settings), so the first lines pair up. A single-line row
/// a bodyweight exercise with no settings centers against the checkbox
/// instead of hanging off its top edge.
private var rowAlignment: VerticalAlignment {
showsWeight || settingsSummary != nil ? .top : .center
}
var body: some View {
HStack(alignment: .top) {
HStack(alignment: rowAlignment) {
Button {
onCheckboxTap()
} label: {
@@ -428,44 +440,44 @@ private struct WorkoutLogRow: View {
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: 4) {
Text(log.exerciseName)
.font(.headline)
.foregroundColor(.primary)
Text(setsAndReps)
.font(.title3.weight(.semibold))
.monospacedDigit()
.foregroundStyle(.primary)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
if showsWeight {
Text(weightUnit.format(log.weight))
.font(.title3.weight(.semibold))
.monospacedDigit()
.foregroundStyle(.primary)
}
// Machine comfort settings shown when the log carries settings, or
// for any exercise the authored library identifies as machine-based
// (same rule as ExerciseView), so older logs offer the affordance
// before settings are first recorded. Tapping opens the editor sheet;
// `.plain` keeps the tap from firing the row's NavigationLink (same
// trick as the checkbox button).
if log.machineSettings != nil
|| ExerciseInfoLibrary.info(for: log.exerciseName)?.isMachineBased == true {
// Machine comfort settings the line appears only once settings are
// actually recorded (an unconfigured machine shows nothing; recording
// the first values happens in the exercise's edit screen or progress
// flow). Tapping opens the editor sheet; `.plain` keeps the tap from
// firing the row's NavigationLink (same trick as the checkbox).
if let settingsSummary {
Button(action: onSettingsTap) {
Label(settingsSummary ?? "Settings", systemImage: "slider.horizontal.3")
.font(.footnote.weight(.medium))
.foregroundStyle(Color.accentColor)
.multilineTextAlignment(.trailing)
Label(settingsSummary, systemImage: "slider.horizontal.3")
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(2)
}
.buttonStyle(.plain)
}
}
Spacer()
// The weight matches the exercise name's font so the two sit on the
// same visual line; sets × reps keeps its larger glanceable size below.
VStack(alignment: .trailing, spacing: 4) {
if showsWeight {
Text(weightUnit.format(log.weight))
.font(.headline)
.monospacedDigit()
.foregroundStyle(.primary)
}
setsAndReps
.font(.title3)
.monospacedDigit()
.foregroundStyle(.primary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}