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:
@@ -4,7 +4,6 @@
|
||||
"createdAt" : "2020-01-01T00:00:00Z",
|
||||
"exercises" : [
|
||||
{
|
||||
"category" : 1,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00AHY49HPHA00JGDQY",
|
||||
"loadType" : 0,
|
||||
@@ -12,11 +11,9 @@
|
||||
"order" : 0,
|
||||
"reps" : 10,
|
||||
"sets" : 1,
|
||||
"weight" : 0,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 0
|
||||
},
|
||||
{
|
||||
"category" : 1,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT0021S72PRVXDA0HCNB",
|
||||
"loadType" : 0,
|
||||
@@ -24,11 +21,9 @@
|
||||
"order" : 1,
|
||||
"reps" : 8,
|
||||
"sets" : 1,
|
||||
"weight" : 0,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 0
|
||||
},
|
||||
{
|
||||
"category" : 1,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00K2AHTEHR3HRVKGEG",
|
||||
"loadType" : 0,
|
||||
@@ -36,11 +31,9 @@
|
||||
"order" : 2,
|
||||
"reps" : 8,
|
||||
"sets" : 1,
|
||||
"weight" : 0,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 0
|
||||
},
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 45,
|
||||
"id" : "01DXF6DT00V256F3TPS99MCP5F",
|
||||
"loadType" : 2,
|
||||
@@ -48,11 +41,9 @@
|
||||
"order" : 3,
|
||||
"reps" : 0,
|
||||
"sets" : 3,
|
||||
"weight" : 0,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 0
|
||||
},
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 30,
|
||||
"id" : "01DXF6DT007YMYCAYYNJEFWTQ5",
|
||||
"loadType" : 2,
|
||||
@@ -60,11 +51,9 @@
|
||||
"order" : 4,
|
||||
"reps" : 0,
|
||||
"sets" : 3,
|
||||
"weight" : 0,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 0
|
||||
},
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 30,
|
||||
"id" : "01DXF6DT00WXCASZG1VEZRPT0Y",
|
||||
"loadType" : 2,
|
||||
@@ -72,11 +61,9 @@
|
||||
"order" : 5,
|
||||
"reps" : 0,
|
||||
"sets" : 3,
|
||||
"weight" : 0,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 0
|
||||
},
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00VMWCDF9QCB28WR20",
|
||||
"loadType" : 0,
|
||||
@@ -84,11 +71,9 @@
|
||||
"order" : 6,
|
||||
"reps" : 12,
|
||||
"sets" : 3,
|
||||
"weight" : 0,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 0
|
||||
},
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00MJH9N622QTMM7XB3",
|
||||
"loadType" : 0,
|
||||
@@ -96,11 +81,9 @@
|
||||
"order" : 7,
|
||||
"reps" : 8,
|
||||
"sets" : 3,
|
||||
"weight" : 0,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 0
|
||||
},
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00Z98NR23ESEEKGQH1",
|
||||
"loadType" : 0,
|
||||
@@ -108,14 +91,13 @@
|
||||
"order" : 8,
|
||||
"reps" : 12,
|
||||
"sets" : 3,
|
||||
"weight" : 0,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 0
|
||||
}
|
||||
],
|
||||
"id" : "01DXF6DT00RV99WG172YFW4NKA",
|
||||
"name" : "Bodyweight Core",
|
||||
"order" : 3,
|
||||
"schemaVersion" : 1,
|
||||
"schemaVersion" : 2,
|
||||
"systemImage" : "figure.core.training",
|
||||
"updatedAt" : "2020-01-01T00:00:00Z"
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
"createdAt" : "2020-01-01T00:00:00Z",
|
||||
"exercises" : [
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00C4K0NY1NRCFPB3XY",
|
||||
"loadType" : 1,
|
||||
@@ -12,11 +11,9 @@
|
||||
"order" : 0,
|
||||
"reps" : 10,
|
||||
"sets" : 4,
|
||||
"weight" : 0,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 40
|
||||
},
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT001AY3SXTEYHA94MHZ",
|
||||
"loadType" : 1,
|
||||
@@ -24,14 +21,13 @@
|
||||
"order" : 1,
|
||||
"reps" : 10,
|
||||
"sets" : 4,
|
||||
"weight" : 0,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 40
|
||||
}
|
||||
],
|
||||
"id" : "01DXF6DT001MA0TM7FHJZT098Z",
|
||||
"name" : "Core",
|
||||
"order" : 1,
|
||||
"schemaVersion" : 1,
|
||||
"schemaVersion" : 2,
|
||||
"systemImage" : "figure.core.training",
|
||||
"updatedAt" : "2020-01-01T00:00:00Z"
|
||||
}
|
||||
@@ -4,58 +4,70 @@
|
||||
"createdAt" : "2020-01-01T00:00:00Z",
|
||||
"exercises" : [
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00GV3336R9C25W4X2A",
|
||||
"loadType" : 1,
|
||||
"name" : "Abductor",
|
||||
"name" : "Leg Press",
|
||||
"order" : 0,
|
||||
"reps" : 10,
|
||||
"sets" : 4,
|
||||
"weight" : 140,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 140
|
||||
},
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00Z28DJ7PSP7Q11CJH",
|
||||
"loadType" : 1,
|
||||
"name" : "Adductor",
|
||||
"name" : "Leg Curl",
|
||||
"order" : 1,
|
||||
"reps" : 10,
|
||||
"sets" : 4,
|
||||
"weight" : 140,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 40
|
||||
},
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00D53Q4QMWAE1BHXR6",
|
||||
"loadType" : 1,
|
||||
"name" : "Leg Press",
|
||||
"name" : "Leg Extension",
|
||||
"order" : 2,
|
||||
"reps" : 10,
|
||||
"sets" : 4,
|
||||
"weight" : 160,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 80
|
||||
},
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT006HW2KWNA5BCCDCJE",
|
||||
"loadType" : 1,
|
||||
"name" : "Leg Curl",
|
||||
"name" : "Abductor",
|
||||
"order" : 3,
|
||||
"reps" : 10,
|
||||
"sets" : 4,
|
||||
"weight" : 70,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 130
|
||||
},
|
||||
{
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00H5B5YD16868Y95ZQ",
|
||||
"loadType" : 1,
|
||||
"name" : "Adductor",
|
||||
"order" : 4,
|
||||
"reps" : 10,
|
||||
"sets" : 4,
|
||||
"weight" : 130
|
||||
},
|
||||
{
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT002S88DB6NKYZS4EAW",
|
||||
"loadType" : 1,
|
||||
"name" : "Calfs",
|
||||
"order" : 5,
|
||||
"reps" : 10,
|
||||
"sets" : 4,
|
||||
"weight" : 100
|
||||
}
|
||||
],
|
||||
"id" : "01DXF6DT006QRF1PMGK17FV505",
|
||||
"name" : "Lower Body",
|
||||
"order" : 2,
|
||||
"schemaVersion" : 1,
|
||||
"schemaVersion" : 2,
|
||||
"systemImage" : "figure.run",
|
||||
"updatedAt" : "2020-01-01T00:00:00Z"
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
"createdAt" : "2020-01-01T00:00:00Z",
|
||||
"exercises" : [
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00N1B90PA7K5ABWSSP",
|
||||
"loadType" : 1,
|
||||
@@ -12,50 +11,63 @@
|
||||
"order" : 0,
|
||||
"reps" : 10,
|
||||
"sets" : 4,
|
||||
"weight" : 110,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 110
|
||||
},
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00R81QRWGCCQG933FZ",
|
||||
"loadType" : 1,
|
||||
"name" : "Tricep Press",
|
||||
"name" : "Seated Row",
|
||||
"order" : 1,
|
||||
"reps" : 10,
|
||||
"sets" : 4,
|
||||
"weight" : 100,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 90
|
||||
},
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT000FX4241G5MPG4WG2",
|
||||
"loadType" : 1,
|
||||
"name" : "Chest Press",
|
||||
"name" : "Shoulder Press",
|
||||
"order" : 2,
|
||||
"reps" : 10,
|
||||
"sets" : 4,
|
||||
"weight" : 40,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 40
|
||||
},
|
||||
{
|
||||
"category" : 0,
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00AD5TSVJPZZVA2S41",
|
||||
"loadType" : 1,
|
||||
"name" : "Seated Row",
|
||||
"name" : "Chest Press",
|
||||
"order" : 3,
|
||||
"reps" : 10,
|
||||
"sets" : 4,
|
||||
"weight" : 90,
|
||||
"weightReminderWeeks" : 2
|
||||
"weight" : 40
|
||||
},
|
||||
{
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT006QZ6XT95301HKJT8",
|
||||
"loadType" : 1,
|
||||
"name" : "Tricep Press",
|
||||
"order" : 4,
|
||||
"reps" : 10,
|
||||
"sets" : 4,
|
||||
"weight" : 100
|
||||
},
|
||||
{
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT009J5H4Z0XPHBT1H0K",
|
||||
"loadType" : 1,
|
||||
"name" : "Arm Curl",
|
||||
"order" : 5,
|
||||
"reps" : 10,
|
||||
"sets" : 4,
|
||||
"weight" : 40
|
||||
}
|
||||
],
|
||||
"id" : "01DXF6DT0038BDC2WC3EVX8ZJ5",
|
||||
"name" : "Upper Body",
|
||||
"order" : 0,
|
||||
"schemaVersion" : 1,
|
||||
"schemaVersion" : 2,
|
||||
"systemImage" : "figure.strengthtraining.traditional",
|
||||
"updatedAt" : "2020-01-01T00:00:00Z"
|
||||
}
|
||||
@@ -29,8 +29,10 @@ struct ScreenshotRootView: View {
|
||||
if let workout = activeWorkout {
|
||||
switch ScreenshotSeed.screen(default: "workouts") {
|
||||
case "exercise":
|
||||
let logID = WorkoutDocument(from: workout).logs.first { $0.exerciseName == "Bench Press" }?.id
|
||||
let logID = WorkoutDocument(from: workout).logs.first { $0.exerciseName == "Seated Row" }?.id
|
||||
NavigationStack { ExerciseView(workout: workout, logID: logID ?? "") }
|
||||
case "run":
|
||||
ScreenshotRunView(workout: workout)
|
||||
case "settings":
|
||||
SettingsView()
|
||||
default:
|
||||
@@ -41,4 +43,26 @@ struct ScreenshotRootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The paged run flow for the in-progress exercise, showing the animated form guide
|
||||
/// (with machine props) — the screenshot works off its own local document copy, so
|
||||
/// nothing persists.
|
||||
private struct ScreenshotRunView: View {
|
||||
@State private var doc: WorkoutDocument
|
||||
private let logID: String
|
||||
|
||||
init(workout: Workout) {
|
||||
let document = WorkoutDocument(from: workout)
|
||||
_doc = State(initialValue: document)
|
||||
logID = document.logs.first { $0.exerciseName == "Seated Row" }?.id
|
||||
?? document.logs.first?.id ?? ""
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ExerciseProgressView(doc: $doc, logID: logID, onChange: {},
|
||||
onLive: { _ in }, onLiveEnded: {}, incomingFrame: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
//
|
||||
// CheckboxListItem.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 10:42 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CheckboxListItem: View {
|
||||
var status: CheckboxStatus
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var count: Int?
|
||||
var onCheckboxTap: (() -> Void)? = nil
|
||||
|
||||
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) {
|
||||
Text("\(title)")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
HStack(alignment: .bottom) {
|
||||
if let subtitle = subtitle {
|
||||
Text("\(subtitle)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if let count = count {
|
||||
Text("\(count)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,8 @@ enum CheckboxStatus {
|
||||
switch self {
|
||||
case .checked: .accentColor
|
||||
case .unchecked: .gray
|
||||
case .intermediate: .gray
|
||||
case .cancelled: .red
|
||||
case .intermediate: .accentColor
|
||||
case .cancelled: .gray
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ enum CheckboxStatus {
|
||||
case .checked: "checkmark.circle.fill"
|
||||
case .unchecked: "circle"
|
||||
case .intermediate: "ellipsis.circle"
|
||||
case .cancelled: "xmark.circle"
|
||||
case .cancelled: "wrongwaysign"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// MachineSettingsEditor.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Reusable editor for an ordered list of machine comfort settings (seat height,
|
||||
/// back-rest incline, pin position…). Drop it into a `Form`/`List` `Section`: it
|
||||
/// renders one `name`/`value` row per setting with swipe-to-delete and
|
||||
/// drag-to-reorder, plus an "Add Setting" row. Shared by the exercise add/edit
|
||||
/// screen (Surface 1) and the in-workout settings sheet (Surface 2).
|
||||
///
|
||||
/// `MachineSetting` is a plain Codable wire type with no identity, so the editor
|
||||
/// keeps a per-row `UUID` internally (stable across edits/reorders, unlike an
|
||||
/// index) and mirrors changes back into the `settings` binding. The editor is the
|
||||
/// sole mutator of `settings` while it's on screen, so a one-way seed at init is
|
||||
/// enough — there's no external write to reconcile.
|
||||
struct MachineSettingsEditor: View {
|
||||
@Binding var settings: [MachineSetting]
|
||||
@State private var rows: [Row]
|
||||
|
||||
init(settings: Binding<[MachineSetting]>) {
|
||||
_settings = settings
|
||||
_rows = State(initialValue: settings.wrappedValue.map(Row.init))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ForEach($rows) { $row in
|
||||
HStack {
|
||||
TextField("Setting", text: $row.name)
|
||||
.textInputAutocapitalization(.words)
|
||||
TextField("Value", text: $row.value)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 120)
|
||||
}
|
||||
}
|
||||
.onDelete { rows.remove(atOffsets: $0) }
|
||||
.onMove { rows.move(fromOffsets: $0, toOffset: $1) }
|
||||
|
||||
Button {
|
||||
withAnimation { rows.append(Row(MachineSetting(name: "", value: ""))) }
|
||||
} label: {
|
||||
Label("Add Setting", systemImage: "plus.circle.fill")
|
||||
}
|
||||
// Mirror the identified rows back into the plain wire-type binding on every
|
||||
// edit/add/delete/reorder.
|
||||
.onChange(of: rows) { _, newRows in
|
||||
settings = newRows.map(\.setting)
|
||||
}
|
||||
}
|
||||
|
||||
/// Session-only identified wrapper so `ForEach` and reordering have a stable
|
||||
/// identity that survives text edits.
|
||||
fileprivate struct Row: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
var name: String
|
||||
var value: String
|
||||
|
||||
init(_ setting: MachineSetting) {
|
||||
name = setting.name
|
||||
value = setting.value
|
||||
}
|
||||
|
||||
var setting: MachineSetting { MachineSetting(name: name, value: value) }
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ struct ExerciseAddEditView: View {
|
||||
|
||||
// Local editable state
|
||||
@State private var exerciseName: String
|
||||
@State private var originalWeight: Int
|
||||
@State private var loadType: LoadType
|
||||
@State private var minutes: Int
|
||||
@State private var seconds: Int
|
||||
@@ -31,9 +30,11 @@ struct ExerciseAddEditView: View {
|
||||
@State private var weightOnes: Int
|
||||
@State private var reps: Int
|
||||
@State private var sets: Int
|
||||
@State private var weightReminderWeeks: Int
|
||||
@State private var weightLastUpdated: Date?
|
||||
@State private var category: ExerciseCategory
|
||||
// Machine comfort settings. `machineEnabled` mirrors the optionality of the
|
||||
// stored `machineSettings` — ON persists a (possibly empty) array, OFF persists
|
||||
// nil. `machineSettings` holds the ordered rows while the toggle is on.
|
||||
@State private var machineEnabled: Bool
|
||||
@State private var machineSettings: [MachineSetting]
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
init(exercise: Exercise, split: Split) {
|
||||
@@ -42,7 +43,6 @@ struct ExerciseAddEditView: View {
|
||||
|
||||
let w = exercise.weight
|
||||
_exerciseName = State(initialValue: exercise.name)
|
||||
_originalWeight = State(initialValue: w)
|
||||
_loadType = State(initialValue: exercise.loadTypeEnum)
|
||||
_minutes = State(initialValue: exercise.durationMinutes)
|
||||
_seconds = State(initialValue: exercise.durationSeconds)
|
||||
@@ -50,9 +50,8 @@ struct ExerciseAddEditView: View {
|
||||
_weightOnes = State(initialValue: w % 10)
|
||||
_reps = State(initialValue: exercise.reps)
|
||||
_sets = State(initialValue: exercise.sets)
|
||||
_weightReminderWeeks = State(initialValue: exercise.weightReminderTimeIntervalWeeks)
|
||||
_weightLastUpdated = State(initialValue: exercise.weightLastUpdated)
|
||||
_category = State(initialValue: exercise.categoryEnum)
|
||||
_machineEnabled = State(initialValue: exercise.machineSettings != nil)
|
||||
_machineSettings = State(initialValue: exercise.machineSettings ?? [])
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -100,19 +99,6 @@ struct ExerciseAddEditView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section(
|
||||
header: Text("Category"),
|
||||
footer: Text("The split screen groups its exercises by category, with the warm-up listed first.")
|
||||
) {
|
||||
Picker("", selection: $category) {
|
||||
ForEach(ExerciseCategory.displayOrder, id: \.self) { cat in
|
||||
Text(cat.displayName)
|
||||
.tag(cat)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
Section(
|
||||
header: Text("Load Type"),
|
||||
footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.")
|
||||
@@ -170,16 +156,17 @@ struct ExerciseAddEditView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Weight Increase")) {
|
||||
HStack {
|
||||
Text("Remind every \(weightReminderWeeks) weeks")
|
||||
Spacer()
|
||||
Stepper("", value: $weightReminderWeeks, in: 0...366)
|
||||
}
|
||||
if let lastUpdated = weightLastUpdated {
|
||||
Text("Last weight change \(Date().humanTimeInterval(to: lastUpdated)) ago")
|
||||
Section(
|
||||
header: Text("Machine"),
|
||||
footer: Text("Turn on for machine-based exercises to record comfort settings (seat height, back-rest incline, pin position…). During a workout you can adjust these and update this default.")
|
||||
) {
|
||||
Toggle("Machine-based", isOn: $machineEnabled.animation())
|
||||
|
||||
if machineEnabled {
|
||||
MachineSettingsEditor(settings: $machineSettings)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.sheet(isPresented: $showingExercisePicker) {
|
||||
ExercisePickerView { exerciseNames in
|
||||
@@ -206,7 +193,6 @@ struct ExerciseAddEditView: View {
|
||||
|
||||
private func saveExercise() {
|
||||
let newWeight = weightTens + weightOnes
|
||||
let updatedWeightDate: Date? = newWeight != originalWeight ? Date() : weightLastUpdated
|
||||
let durationSecs = minutes * 60 + seconds
|
||||
|
||||
var doc = SplitDocument(from: split)
|
||||
@@ -217,9 +203,8 @@ struct ExerciseAddEditView: View {
|
||||
doc.exercises[idx].weight = newWeight
|
||||
doc.exercises[idx].loadType = loadType.rawValue
|
||||
doc.exercises[idx].durationSeconds = durationSecs
|
||||
doc.exercises[idx].weightLastUpdated = updatedWeightDate
|
||||
doc.exercises[idx].weightReminderWeeks = weightReminderWeeks
|
||||
doc.exercises[idx].category = category.rawValue
|
||||
// ON → a (possibly empty) array; OFF → nil, discarding any prior settings.
|
||||
doc.exercises[idx].machineSettings = machineEnabled ? machineSettings : nil
|
||||
}
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
|
||||
@@ -226,14 +226,7 @@ struct ExerciseListView: View {
|
||||
guard let split else { return }
|
||||
let startDate = Date()
|
||||
let logs = split.exercisesArray.enumerated().map { i, ex in
|
||||
WorkoutLogDocument(
|
||||
id: ULID.make(), exerciseName: ex.name, order: i,
|
||||
sets: ex.sets, reps: ex.reps, weight: ex.weight,
|
||||
loadType: ex.loadType, durationSeconds: ex.durationTotalSeconds,
|
||||
currentStateIndex: 0, completed: false,
|
||||
status: WorkoutStatus.notStarted.rawValue,
|
||||
notes: nil, date: startDate
|
||||
)
|
||||
WorkoutLogDocument(planFrom: ExerciseDocument(from: ex), order: i, date: startDate)
|
||||
}
|
||||
let doc = WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion,
|
||||
@@ -266,7 +259,7 @@ struct ExerciseListView: View {
|
||||
id: ULID.make(), name: exName, order: base + i,
|
||||
sets: 3, reps: 10, weight: 40,
|
||||
loadType: LoadType.weight.rawValue,
|
||||
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
|
||||
durationSeconds: 0
|
||||
)
|
||||
}
|
||||
doc.exercises.append(contentsOf: newDocs)
|
||||
|
||||
@@ -63,51 +63,46 @@ struct SplitDetailView: View {
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
// One section per category (warm-up first). A split with no warm-ups keeps
|
||||
// the single plain "Exercises" section it always had.
|
||||
// Headerless — what the exercise list is needs no label; the section
|
||||
// itself keeps the visual separation.
|
||||
if split.exercisesArray.isEmpty {
|
||||
Section(header: Text("Exercises")) {
|
||||
Section {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingExerciseAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let grouped = groupedExercises(for: split)
|
||||
ForEach(grouped, id: \.category) { group in
|
||||
Section(header: Text(grouped.count == 1 ? "Exercises" : group.category.displayName)) {
|
||||
ForEach(group.exercises) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: item.planSummary(weightUnit: weightUnit)
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.onMove { source, destination in
|
||||
moveExercises(in: group.category, from: source, to: destination)
|
||||
}
|
||||
|
||||
if group.category == grouped.last?.category {
|
||||
Section {
|
||||
ForEach(split.exercisesArray) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: item.planSummary(weightUnit: weightUnit)
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
showingExerciseAddSheet = true
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
ListItem(systemName: "plus.circle", title: "Add Exercise")
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.onMove { source, destination in
|
||||
moveExercises(from: source, to: destination)
|
||||
}
|
||||
|
||||
Button {
|
||||
showingExerciseAddSheet = true
|
||||
} label: {
|
||||
ListItem(systemName: "plus.circle", title: "Add Exercise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,26 +150,14 @@ struct SplitDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercises bucketed by category in display order, dropping empty buckets.
|
||||
private func groupedExercises(for split: Split) -> [(category: ExerciseCategory, exercises: [Exercise])] {
|
||||
let all = split.exercisesArray
|
||||
return ExerciseCategory.displayOrder.compactMap { category in
|
||||
let members = all.filter { $0.categoryEnum == category }
|
||||
return members.isEmpty ? nil : (category, members)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reorder within one category's section, then renumber globally with the
|
||||
/// sections' display order (warm-ups first) as the canonical file order. Resolves
|
||||
/// the current split at call time so it follows a clone-on-edit.
|
||||
private func moveExercises(in category: ExerciseCategory, from source: IndexSet, to destination: Int) {
|
||||
/// Reorder and renumber. Resolves the current split at call time so it
|
||||
/// follows a clone-on-edit.
|
||||
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||||
guard let split else { return }
|
||||
var groups = groupedExercises(for: split)
|
||||
guard let gi = groups.firstIndex(where: { $0.category == category }) else { return }
|
||||
groups[gi].exercises.move(fromOffsets: source, toOffset: destination)
|
||||
var ordered = split.exercisesArray
|
||||
ordered.move(fromOffsets: source, toOffset: destination)
|
||||
|
||||
var doc = SplitDocument(from: split)
|
||||
let ordered = groups.flatMap(\.exercises)
|
||||
doc.exercises = ordered.enumerated().map { i, ex in
|
||||
var ed = ExerciseDocument(from: ex)
|
||||
ed.order = i
|
||||
@@ -197,7 +180,7 @@ struct SplitDetailView: View {
|
||||
id: ULID.make(), name: exName, order: base + i,
|
||||
sets: 3, reps: 10, weight: 40,
|
||||
loadType: LoadType.weight.rawValue,
|
||||
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
|
||||
durationSeconds: 0
|
||||
)
|
||||
}
|
||||
doc.exercises.append(contentsOf: newDocs)
|
||||
|
||||
@@ -10,12 +10,19 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// The Splits library screen, pushed from Settings → Library: a two-column grid of
|
||||
/// split tiles. Tapping a tile opens `SplitDetailView`; long-pressing offers Delete
|
||||
/// (the sole split-delete affordance); the toolbar's + adds a new split. Starter
|
||||
/// splits are seeded automatically on the true first run (`SyncEngine.autoSeedIfEmpty`),
|
||||
/// so an empty library just invites creating one.
|
||||
struct SplitListView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@Query(sort: \Split.order) private var splits: [Split]
|
||||
|
||||
@State private var showingAddSheet = false
|
||||
@State private var splitToDelete: Split?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
|
||||
@@ -25,6 +32,13 @@ struct SplitListView: View {
|
||||
} label: {
|
||||
SplitItem(split: split)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
splitToDelete = split
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
@@ -32,20 +46,44 @@ struct SplitListView: View {
|
||||
.overlay {
|
||||
if splits.isEmpty {
|
||||
ContentUnavailableView(
|
||||
label: {
|
||||
Label("No Splits Yet", systemImage: "dumbbell.fill")
|
||||
},
|
||||
description: {
|
||||
Text("Create a split to organize your workout routine.")
|
||||
},
|
||||
actions: {
|
||||
Button("Add Starter Splits") {
|
||||
Task { await SplitSeeder.seedDefaults(into: modelContext, using: sync) }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
"No Splits Yet",
|
||||
systemImage: "dumbbell.fill",
|
||||
description: Text("Create a split to organize your workout routine.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Splits")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Add Split")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
SplitAddEditView(split: nil)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Split?",
|
||||
isPresented: Binding(
|
||||
get: { splitToDelete != nil },
|
||||
set: { if !$0 { splitToDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: splitToDelete
|
||||
) { split in
|
||||
Button("Delete", role: .destructive) {
|
||||
Task { await sync.delete(split: split) }
|
||||
splitToDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
splitToDelete = nil
|
||||
}
|
||||
} message: { split in
|
||||
Text("This will permanently delete \"\(split.name)\" and all its exercises. Past workouts are kept.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
//
|
||||
// SplitsView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/17/25 at 6:55 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SplitsView: View {
|
||||
@State private var showingAddSheet: Bool = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
SplitListView()
|
||||
.navigationTitle("Splits")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { showingAddSheet.toggle() }) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
SplitAddEditView(split: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 file→cache 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 file→cache
|
||||
/// 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 file→cache 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.
|
||||
|
||||
Reference in New Issue
Block a user