Add machine comfort settings and tighten the document schemas

Machine-based exercises now carry ordered name/value comfort settings
(seat height, back-rest position, ...) on both ExerciseDocument and, as a
plan-time snapshot, WorkoutLogDocument — the optional array doubles as the
machine flag. Editable from the exercise editor's new Machine section and
from the workout row's settings sheet, whose mid-workout edits write back
to the originating split (workout saved first, so seed clone-on-edit
repointing can't clobber the log edit).

Schema tightening rides the same rev: splits bump to v2 (weight-reminder
fields and the unused exercise category removed), workouts to v3 (the
derived `completed` flag removed; status is the single source). Starter
seeds regenerated at v2 with unchanged ULIDs; SwiftData cache schema
bumped to rebuild. SCHEMA.md documents the shapes.

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
2026-07-06 16:29:44 -04:00
parent fce8fa4c17
commit 2c1e4759ae
33 changed files with 1132 additions and 586 deletions
@@ -91,6 +91,11 @@ struct ExerciseProgressView: View {
/// so completing the exercise from inside the flow doesn't swap the page mid-dismiss.
@State private var startsCompleted: Bool
/// True when the exercise was skipped (workout ended early) when this screen opened
/// it shows a static Skipped page instead of the timer flow. Fixed at init like
/// `startsCompleted`.
@State private var startsSkipped: Bool
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
self._doc = doc
self.logID = logID
@@ -109,6 +114,7 @@ struct ExerciseProgressView: View {
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
_startsResumed = State(initialValue: !notStarted)
_startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue)
_startsSkipped = State(initialValue: log?.status == WorkoutStatus.skipped.rawValue)
let base = 1
// Resume on the first unfinished set's work page (clamped to the last set).
@@ -189,9 +195,21 @@ struct ExerciseProgressView: View {
var body: some View {
if startsCompleted {
CompletedPhaseView()
.navigationTitle(log?.exerciseName ?? "")
.navigationBarTitleDisplayMode(.inline)
// Same top/bottom split as the run flow, with the Completed badge where
// the paged flow would be, so the form-guide figure stays on screen.
VStack(spacing: 0) {
CompletedPhaseView(log: log)
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
}
.navigationTitle(log?.exerciseName ?? "")
.navigationBarTitleDisplayMode(.inline)
} else if startsSkipped {
VStack(spacing: 0) {
SkippedPhaseView()
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
}
.navigationTitle(log?.exerciseName ?? "")
.navigationBarTitleDisplayMode(.inline)
} else {
flowBody
}
@@ -462,7 +480,7 @@ struct ExerciseProgressView: View {
private func beginExercise() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
guard doc.logs[i].status == WorkoutStatus.notStarted.rawValue else { return }
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].transition(to: .inProgress)
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
@@ -476,8 +494,7 @@ struct ExerciseProgressView: View {
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
doc.logs[i].sets = newCount
doc.logs[i].currentStateIndex = newCount - 1 // every prior set is now complete
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
doc.logs[i].transition(to: .inProgress)
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
@@ -504,8 +521,7 @@ struct ExerciseProgressView: View {
guard reached > doc.logs[i].currentStateIndex else { return }
doc.logs[i].currentStateIndex = reached
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
doc.logs[i].transition(to: .inProgress)
recomputeWorkoutStatus()
doc.updatedAt = Date()
@@ -518,11 +534,9 @@ struct ExerciseProgressView: View {
let log = doc.logs[i]
// Skip the write if it's already pristine (e.g. landing on Ready before any set).
guard log.currentStateIndex != 0
|| log.status != WorkoutStatus.notStarted.rawValue
|| log.completed else { return }
|| log.status != WorkoutStatus.notStarted.rawValue else { return }
doc.logs[i].currentStateIndex = 0
doc.logs[i].status = WorkoutStatus.notStarted.rawValue
doc.logs[i].completed = false
doc.logs[i].transition(to: .notStarted)
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
@@ -531,8 +545,7 @@ struct ExerciseProgressView: View {
private func completeExercise() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].currentStateIndex = setCount
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
doc.logs[i].transition(to: .completed)
recomputeWorkoutStatus()
doc.updatedAt = Date()
@@ -696,15 +709,71 @@ private extension View {
// MARK: - Completed Phase
/// Shown instead of the run flow when the exercise was already completed on open.
/// Shown instead of the run flow when the exercise was already completed on open:
/// the badge, then when it was completed, what was done, and how long it took
/// (the timing lines appear only on logs that carry the timestamps).
private struct CompletedPhaseView: View {
let log: WorkoutLogDocument?
/// "4 sets × 12 reps" / "3 sets × 45 sec".
private var planSummary: String {
guard let log else { return "" }
let setsText = "\(log.sets) set\(log.sets == 1 ? "" : "s")"
if LoadType(rawValue: log.loadType) == .duration {
return "\(setsText) × \(ExerciseProgressView.durationLabel(log.durationSeconds))"
}
return "\(setsText) × \(log.reps) reps"
}
/// Start-to-done wall time, when both timestamps were recorded.
private var elapsed: String? {
guard let log, let start = log.startedAt, let end = log.completedAt else { return nil }
let seconds = Int(end.timeIntervalSince(start))
guard seconds > 0 else { return nil }
return ExerciseProgressView.durationLabel(seconds)
}
var body: some View {
VStack(spacing: 14) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 96))
.foregroundStyle(.green)
.font(.system(size: 96, weight: .bold))
.foregroundStyle(Color.accentColor)
Text("Completed")
.font(.system(.title, design: .rounded, weight: .heavy))
.foregroundStyle(Color.accentColor)
if let log {
VStack(spacing: 4) {
if let completedAt = log.completedAt {
Text(completedAt.formattedRelativeDateTime())
}
Text(planSummary)
if let elapsed {
Text("in \(elapsed)")
}
}
.font(.callout)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Skipped Phase
/// Shown instead of the run flow when the exercise was skipped (the workout was
/// ended early with this one unfinished). Same badge treatment as Completed,
/// in gray, matching the list row's skipped icon.
private struct SkippedPhaseView: View {
var body: some View {
VStack(spacing: 14) {
Image(systemName: "wrongwaysign")
.font(.system(size: 96, weight: .bold))
.foregroundStyle(.gray)
Text("Skipped")
.font(.system(.title, design: .rounded, weight: .heavy))
.foregroundStyle(.gray)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
+8 -104
View File
@@ -12,35 +12,25 @@ import SwiftData
import Charts
struct ExerciseView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Environment(\.dismiss) private var dismiss
let workout: Workout
let logID: String
/// Working copy of the parent workout. Editing a log = editing this doc and
/// re-saving the whole aggregate. Driving the UI from local state (not the
/// cache entity) keeps rapid set taps from racing the filecache update.
/// Working copy of the parent workout. Driving the UI from local state (not the
/// cache entity) lets `seedDoc` show a just-added log before the filecache
/// round-trip catches up.
@State private var doc: WorkoutDocument
@State private var progress: Int = 0
@State private var showingPlanEdit = false
@State private var showingNotesEdit = false
let notStartedColor = Color.white
let completedColor = Color.green
/// `seedDoc` lets the caller hand over an in-memory document (e.g. the parent's
/// working copy right after adding an exercise) so the screen doesn't wait on
/// the filecache round-trip to find the just-created log.
init(workout: Workout, logID: String, seedDoc: WorkoutDocument? = nil) {
self.workout = workout
self.logID = logID
let initialDoc = seedDoc ?? WorkoutDocument(from: workout)
_doc = State(initialValue: initialDoc)
// Seed progress from the log so the set grid is correct on the first frame
// (onAppear also refreshes it, but that lags the initial render).
_progress = State(initialValue: initialDoc.logs.first { $0.id == logID }?.currentStateIndex ?? 0)
_doc = State(initialValue: seedDoc ?? WorkoutDocument(from: workout))
}
/// The log being edited within the working doc.
@@ -66,7 +56,6 @@ struct ExerciseView: View {
}
.onAppear {
refreshDocIfNeeded()
progress = log?.currentStateIndex ?? 0
// Take over this run: the watch parks and locks it while we're editing here.
services.watchBridge.setEditingWorkout(workout.id)
}
@@ -81,60 +70,15 @@ struct ExerciseView: View {
}
}
/// Pull an externally-changed workout into the working copy so the set grid and plan
/// Pull an externally-changed workout into the working copy so the plan and notes
/// update without leaving and re-entering the screen.
private func absorbExternalUpdate() {
let fresh = WorkoutDocument(from: workout)
doc = fresh
progress = fresh.logs.first(where: { $0.id == logID })?.currentStateIndex ?? progress
doc = WorkoutDocument(from: workout)
}
@ViewBuilder
private func content(for log: WorkoutLogDocument) -> some View {
Form {
// MARK: - Progress Section
Section(header: Text("Progress")) {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: max(1, log.sets)), spacing: 4) {
ForEach(1...max(1, log.sets), id: \.self) { index in
ZStack {
let completed = index <= progress
let color = completed ? completedColor : notStartedColor
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
gradient: Gradient(colors: [color, color.darker(by: 0.2)]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.aspectRatio(0.618, contentMode: .fit)
.shadow(radius: 2)
Text("\(index)")
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
.colorInvert()
}
.onTapGesture {
let totalSets = log.sets
let isLastTile = index == totalSets
let wasAlreadyAtThisProgress = progress == index
withAnimation(.easeInOut(duration: 0.2)) {
progress = wasAlreadyAtThisProgress ? 0 : index
}
updateLogStatus()
// Tapping the final tile to complete returns to the list.
if isLastTile && !wasAlreadyAtThisProgress {
dismiss()
}
}
}
}
}
// MARK: - Plan Section (Read-only with Edit button)
Section {
PlanTilesView(log: log)
@@ -186,34 +130,6 @@ struct ExerciseView: View {
}
}
// MARK: - Mutations
private func updateLogStatus() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].currentStateIndex = progress
if progress >= doc.logs[i].sets {
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
} else if progress > 0 {
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
} else {
doc.logs[i].status = WorkoutStatus.notStarted.rawValue
doc.logs[i].completed = false
}
recomputeWorkoutStatus()
doc.updatedAt = Date()
let snapshot = doc
Task { await sync.save(workout: snapshot) }
}
/// Recompute the workout's status/end from its logs.
private func recomputeWorkoutStatus() {
doc.recomputeStatusFromLogs()
}
/// If the requested log isn't in the working doc yet (just-added race), pull a
/// fresh copy from the cache entity once it catches up.
private func refreshDocIfNeeded() {
@@ -222,21 +138,9 @@ struct ExerciseView: View {
}
/// Re-read the workout from the cache to absorb edits made by child sheets
/// (plan/notes) without clobbering progress edits made here.
/// (plan/notes).
private func refreshDocFromCache() {
let fresh = WorkoutDocument(from: workout)
// Preserve the locally edited progress for the open log if the cache lags.
if let i = fresh.logs.firstIndex(where: { $0.id == logID }),
let mine = doc.logs.first(where: { $0.id == logID }),
fresh.logs[i].currentStateIndex != mine.currentStateIndex {
doc = fresh
doc.logs[i].currentStateIndex = mine.currentStateIndex
} else {
doc = fresh
}
if let current = log {
progress = current.currentStateIndex
}
doc = WorkoutDocument(from: workout)
}
}
@@ -160,10 +160,12 @@ struct PlanEditView: View {
let exerciseName = doc.logs[i].exerciseName
let workoutDoc = doc
// 2) Mirror the plan onto the matching exercise in the split template.
// 2) Mirror the plan onto the matching exercise in the split template,
// following the seed clone-on-edit redirect so a prior fork doesn't
// silently break the mirror.
var splitDoc: SplitDocument?
if let splitID = doc.splitID,
let split = CacheMapper.fetchSplit(id: splitID, in: modelContext) {
let split = CacheMapper.fetchSplit(id: sync.currentSplitID(for: splitID), in: modelContext) {
var sDoc = SplitDocument(from: split)
if let ei = sDoc.exercises.firstIndex(where: { $0.name == exerciseName }) {
sDoc.exercises[ei].sets = sets
@@ -20,8 +20,12 @@ struct WeightProgressionChartView: View {
init(exerciseName: String) {
self.exerciseName = exerciseName
let name = exerciseName
// `completed` is derived (status == .completed); the predicate compares the
// stored `statusRaw` column directly, since #Predicate can't call a computed
// property. Capture the raw value in a local so the macro can embed it.
let completedRaw = WorkoutStatus.completed.rawValue
_logs = Query(
filter: #Predicate<WorkoutLog> { $0.exerciseName == name && $0.completed },
filter: #Predicate<WorkoutLog> { $0.exerciseName == name && $0.statusRaw == completedRaw },
sort: \WorkoutLog.date,
order: .forward
)
@@ -32,6 +32,7 @@ struct WorkoutLogListView: View {
@State private var addedLog: LogRoute?
@State private var logToEdit: LogRoute?
@State private var summaryRoute: LogRoute?
@State private var settingsRoute: LogRoute?
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
/// Drives a programmatic push keyed by a log id, used for the freshly-added
@@ -50,10 +51,12 @@ struct WorkoutLogListView: View {
doc.logs.sorted { $0.order < $1.order }
}
/// The split this workout was started from (for adding more exercises).
/// The split this workout was started from (for adding more exercises),
/// following the seed clone-on-edit redirect so a mid-workout fork still
/// resolves to the live clone.
private var split: Split? {
guard let splitID = doc.splitID else { return nil }
return CacheMapper.fetchSplit(id: splitID, in: modelContext)
return CacheMapper.fetchSplit(id: sync.currentSplitID(for: splitID), in: modelContext)
}
var body: some View {
@@ -73,24 +76,27 @@ struct WorkoutLogListView: View {
}
} else {
Form {
if doc.status == WorkoutStatus.completed.rawValue, doc.metrics != nil {
Section("Summary") {
WorkoutMetricsView(workout: workout)
Section {
LabeledContent("Started", value: doc.start.formattedRelativeDateTime())
LabeledContent("Status") {
Text(statusEnum.displayName)
.fontWeight(statusEnum == .inProgress ? .bold : nil)
.foregroundStyle(statusEnum == .inProgress ? Color.accentColor : Color.secondary)
}
}
Section(header: Text(label)) {
Section(header: Text("Exercises")) {
ForEach(sortedLogs) { log in
NavigationLink {
progressView(logID: log.id)
} label: {
CheckboxListItem(
WorkoutLogRow(
status: workoutStatus(log).checkboxStatus,
title: log.exerciseName,
subtitle: subtitleForLog(log)
) {
cycleStatus(for: log)
}
log: log,
weightUnit: weightUnit,
onCheckboxTap: { cycleStatus(for: log) },
onSettingsTap: { settingsRoute = LogRoute(id: log.id) }
)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
@@ -116,15 +122,27 @@ struct WorkoutLogListView: View {
.tint(.blue)
}
}
.onMove(perform: moveLog)
.onMove { source, destination in
moveLog(from: source, to: destination)
}
}
Section {
Button {
showingEndOptions = true
} label: {
Text("End Workout")
.frame(maxWidth: .infinity)
// The trailing slot: a running workout ends here; a finished one
// shows its summary in the button's place.
if doc.status == WorkoutStatus.completed.rawValue {
if doc.metrics != nil {
Section("Summary") {
WorkoutMetricsView(workout: workout)
}
}
} else {
Section {
Button {
showingEndOptions = true
} label: {
Text("End Workout")
.frame(maxWidth: .infinity)
}
}
}
}
@@ -199,6 +217,16 @@ struct WorkoutLogListView: View {
dismiss()
}
}
.sheet(item: $settingsRoute) { route in
if let log = doc.logs.first(where: { $0.id == route.id }) {
MachineSettingsSheet(
exerciseName: log.exerciseName,
initialSettings: log.machineSettings ?? []
) { updated in
applyMachineSettings(updated, toLogID: route.id)
}
}
}
}
/// The paged run flow, fully wired into the live channel: it broadcasts this device's
@@ -220,16 +248,8 @@ struct WorkoutLogListView: View {
// MARK: - Derived
private var label: String {
if doc.status == WorkoutStatus.completed.rawValue, let end = doc.end {
if doc.start.isSameDay(as: end) {
return "\(doc.start.formattedDate())\(end.formattedTime())"
} else {
return "\(doc.start.formattedDate())\(end.formattedDate())"
}
} else {
return doc.start.formattedDate()
}
private var statusEnum: WorkoutStatus {
WorkoutStatus(rawValue: doc.status) ?? .notStarted
}
private func workoutStatus(_ log: WorkoutLogDocument) -> WorkoutStatus {
@@ -253,15 +273,13 @@ struct WorkoutLogListView: View {
case .completed: next = .notStarted
case .skipped: next = .notStarted
}
doc.logs[i].status = next.rawValue
doc.logs[i].completed = (next == .completed)
doc.logs[i].transition(to: next)
save()
}
private func completeLog(_ log: WorkoutLogDocument) {
guard let i = doc.logs.firstIndex(where: { $0.id == log.id }) else { return }
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
doc.logs[i].transition(to: .completed)
save()
}
@@ -292,21 +310,7 @@ struct WorkoutLogListView: View {
}
doc.end = nil
let newLog = WorkoutLogDocument(
id: ULID.make(),
exerciseName: exercise.name,
order: doc.logs.count,
sets: exercise.sets,
reps: exercise.reps,
weight: exercise.weight,
loadType: exercise.loadType,
durationSeconds: exercise.durationTotalSeconds,
currentStateIndex: 0,
completed: false,
status: WorkoutStatus.notStarted.rawValue,
notes: nil,
date: now
)
let newLog = WorkoutLogDocument(planFrom: ExerciseDocument(from: exercise), order: doc.logs.count, date: now)
doc.logs.append(newLog)
save()
@@ -314,6 +318,44 @@ struct WorkoutLogListView: View {
addedLog = LogRoute(id: newLog.id)
}
/// Apply edited machine settings from the sheet: update the log's plan-time
/// snapshot (persisted with the workout), then write the same settings back to
/// the originating split's exercise as the durable default. Sequenced in one
/// task so the split's clone-on-edit repoint (which rewrites this workout's
/// `splitID`) sees the log edit already persisted rather than clobbering it.
private func applyMachineSettings(_ settings: [MachineSetting], toLogID id: String) {
guard let i = doc.logs.firstIndex(where: { $0.id == id }) else { return }
doc.logs[i].machineSettings = settings
doc.recomputeStatusFromLogs()
doc.updatedAt = Date()
let workoutSnapshot = doc
let exerciseName = doc.logs[i].exerciseName
let splitID = doc.splitID
Task {
await sync.save(workout: workoutSnapshot)
await writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID)
}
}
/// Push the settings onto the originating split's exercise (matched by name
/// logs reference exercises by name only), following the seed clone-on-edit
/// redirect so the live clone is updated. Skips silently when the split is gone
/// or no exercise matches, and when nothing changed (so a pristine seed isn't
/// needlessly forked). Never caches the split's id across the save, since saving
/// a seed mints a new one.
private func writeBackMachineSettings(_ settings: [MachineSetting], exerciseName: String, splitID: String?) async {
guard let splitID else { return }
let liveID = sync.currentSplitID(for: splitID)
guard let split = CacheMapper.fetchSplit(id: liveID, in: modelContext) else { return }
var splitDoc = SplitDocument(from: split)
guard let idx = splitDoc.exercises.firstIndex(where: { $0.name == exerciseName }) else { return }
guard splitDoc.exercises[idx].machineSettings != settings else { return }
splitDoc.exercises[idx].machineSettings = settings
splitDoc.updatedAt = Date()
await sync.save(split: splitDoc)
}
/// Recompute the workout's status/end from its logs, then persist.
private func save() {
doc.recomputeStatusFromLogs()
@@ -341,8 +383,26 @@ struct WorkoutLogListView: View {
Task { await sync.delete(workout: target) }
}
private func subtitleForLog(_ log: WorkoutLogDocument) -> String {
if LoadType(rawValue: log.loadType) == .duration {
}
// 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.
private struct WorkoutLogRow: View {
let status: CheckboxStatus
let log: WorkoutLogDocument
let weightUnit: WeightUnit
let onCheckboxTap: () -> Void
let onSettingsTap: () -> Void
private var loadType: LoadType { LoadType(rawValue: log.loadType) ?? .none }
/// Prominent "3 × 12" (or "3 × 5 min" for timed exercises).
private var setsAndReps: String {
if loadType == .duration {
let mins = log.durationSeconds / 60
let secs = log.durationSeconds % 60
if mins > 0 && secs > 0 {
@@ -353,9 +413,61 @@ struct WorkoutLogListView: View {
return "\(log.sets) × \(secs) sec"
}
} else {
return "\(log.sets) × \(log.reps) reps × \(weightUnit.format(log.weight))"
return "\(log.sets) × \(log.reps)"
}
}
private var showsWeight: Bool { loadType == .weight }
var body: some View {
HStack(alignment: .top) {
Button {
onCheckboxTap()
} label: {
Image(systemName: status.systemName)
.resizable()
.scaledToFit()
.frame(width: 30, height: 30)
.foregroundStyle(status.color)
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 2) {
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 for any machine-based log
// (`machineSettings != nil`), independent of load type. 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 {
Button(action: onSettingsTap) {
Label("Settings", systemImage: "slider.horizontal.3")
.font(.footnote.weight(.medium))
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Split Exercise Picker Sheet
@@ -430,3 +542,50 @@ struct SplitExercisePickerSheet: View {
}
}
}
// MARK: - Machine Settings Sheet
/// In-workout editor for a log's machine comfort settings. Only presented for a
/// machine-based log (non-nil `machineSettings`), so there's no machine toggle
/// just the ordered rows (reusing `MachineSettingsEditor`) plus Save/Cancel. Save
/// hands the edited array back to the caller, which persists it to the log and
/// writes it back to the split's exercise as the new default.
private struct MachineSettingsSheet: View {
@Environment(\.dismiss) private var dismiss
let exerciseName: String
@State private var settings: [MachineSetting]
let onSave: ([MachineSetting]) -> Void
init(exerciseName: String, initialSettings: [MachineSetting], onSave: @escaping ([MachineSetting]) -> Void) {
self.exerciseName = exerciseName
self._settings = State(initialValue: initialSettings)
self.onSave = onSave
}
var body: some View {
NavigationStack {
Form {
Section(
header: Text("Settings"),
footer: Text("Saved to this workout and set as the default for \(exerciseName) in its split.")
) {
MachineSettingsEditor(settings: $settings)
}
}
.navigationTitle(exerciseName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
onSave(settings)
dismiss()
}
}
}
}
}
}
@@ -220,21 +220,7 @@ struct SplitPickerSheet: View {
private func start(with split: Split) {
let startDate = Date()
let logs = split.exercisesArray.enumerated().map { index, exercise in
WorkoutLogDocument(
id: ULID.make(),
exerciseName: exercise.name,
order: index,
sets: exercise.sets,
reps: exercise.reps,
weight: exercise.weight,
loadType: exercise.loadType,
durationSeconds: exercise.durationTotalSeconds,
currentStateIndex: 0,
completed: false,
status: WorkoutStatus.notStarted.rawValue,
notes: nil,
date: startDate
)
WorkoutLogDocument(planFrom: ExerciseDocument(from: exercise), order: index, date: startDate)
}
// A freshly started workout has no `end` only completion stamps it.