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
+148
View File
@@ -0,0 +1,148 @@
# SCHEMA.md
The on-disk JSON document schemas — the app's source of truth. The canonical
definitions are the Codable structs in `Shared/Model/Documents.swift` (enums in
`Shared/Model/Enums.swift`); this file is a readable summary. If they disagree,
the code wins.
Encoding (IndieSync `DocumentCoder`): pretty-printed JSON with sorted keys;
dates as ISO-8601 strings. One file per aggregate root:
| Aggregate | Path in iCloud container |
|---|---|
| Split (embeds its exercises) | `Splits/<ULID>.json` |
| Workout (embeds its logs) | `Workouts/YYYY/MM/<ULID>.json` (local-calendar month of `start`) |
| Tombstone (soft-delete stub) | `Stubs/<same relative path as the deleted file>` |
`schemaVersion` gates forward compatibility (`VersionedDocument`): files written
by a newer app version are quarantined, not rewritten. Fields marked *not
schema-bumped* were added as optionals without a version bump — an older app
silently drops them on rewrite, which is accepted as preferable to quarantine.
The same shapes are also the iPhone↔Watch wire format, and each has a mirrored
SwiftData cache entity (`Shared/Model/Entities.swift`) kept in sync by
`Mappers.swift`.
---
## SplitDocument — `currentSchemaVersion: 2`
| Property | Type | Optional | Notes |
|---|---|---|---|
| `schemaVersion` | Int | | Forward-compatibility gate. Bumped 1→2 when the weight-reminder fields and `category` were removed from `ExerciseDocument` and `machineSettings` was added — removing required fields means older apps can't decode new files, so they must quarantine, not rewrite |
| `id` | String | | ULID |
| `name` | String | | |
| `color` | String | | Theme color name |
| `systemImage` | String | | SF Symbol name |
| `order` | Int | | Sort position in the splits list |
| `createdAt` | Date | | |
| `updatedAt` | Date | | |
| `exercises` | [ExerciseDocument] | | Embedded aggregate members |
| `activityType` | Int | ✓ | Raw `WorkoutActivityType`; nil → `traditionalStrength`. *Not schema-bumped* |
## ExerciseDocument (embedded in SplitDocument)
| Property | Type | Optional | Notes |
|---|---|---|---|
| `id` | String | | ULID |
| `name` | String | | Also keys the motion rig / picker catalog |
| `order` | Int | | Sort position within the split |
| `sets` | Int | | |
| `reps` | Int | | |
| `weight` | Int | | Unit-less integer; `WeightUnit` only relabels display |
| `loadType` | Int | | Raw `LoadType` |
| `durationSeconds` | Int | | Total seconds; 0 when not a timed exercise |
| `machineSettings` | [MachineSetting] | ✓ | Ordered machine comfort settings; nil → not a machine exercise, empty → machine exercise with nothing recorded. Doubles as the machine-based flag |
## WorkoutDocument — `currentSchemaVersion: 3`
| Property | Type | Optional | Notes |
|---|---|---|---|
| `schemaVersion` | Int | | Bumped 1→2 when `metrics` was added (captured HR data is irreplaceable, so older apps must quarantine, not strip). Bumped 2→3 when the derived `completed` flag was removed from `WorkoutLogDocument` and `machineSettings` was added — dropping a required field means older apps can't decode new files, so they must quarantine |
| `id` | String | | ULID (chronological — drives month bucketing and sorting) |
| `splitID` | String | ✓ | Denormalized reference; no live relationship |
| `splitName` | String | ✓ | Denormalized snapshot of the split's name |
| `start` | Date | | Also determines the file's `YYYY/MM` bucket (never re-bucketed after write) |
| `end` | Date | ✓ | Stamped by `recomputeStatusFromLogs()` when all logs resolve |
| `status` | String | | Raw `WorkoutStatus`; derived from logs, never set directly |
| `createdAt` | Date | | |
| `updatedAt` | Date | | |
| `logs` | [WorkoutLogDocument] | | Embedded aggregate members |
| `metrics` | WorkoutMetrics | ✓ | Nil until the workout completes and a writer fills it in |
## WorkoutLogDocument (embedded in WorkoutDocument)
| Property | Type | Optional | Notes |
|---|---|---|---|
| `id` | String | | ULID |
| `exerciseName` | String | | Snapshot by name — no reference to the exercise's ULID |
| `order` | Int | | Sort position within the workout |
| `sets` | Int | | Planned sets (snapshot from the exercise) |
| `reps` | Int | | Planned reps (snapshot) |
| `weight` | Int | | Snapshot; unit-less integer |
| `loadType` | Int | | Raw `LoadType` (snapshot) |
| `durationSeconds` | Int | | Total seconds; 0 when not a timed exercise |
| `currentStateIndex` | Int | | Progress through the set sequence |
| `status` | String | | Raw `WorkoutStatus`; mutate only via `transition(to:)`. Completion is derived: `completed == (status == "completed")` |
| `notes` | String | ✓ | |
| `date` | Date | | |
| `machineSettings` | [MachineSetting] | ✓ | Snapshot of the exercise's machine settings at plan time; nil → not a machine exercise, empty → nothing recorded |
| `startedAt` | Date | ✓ | First move to `.inProgress`. *Not schema-bumped* |
| `completedAt` | Date | ✓ | Last move to `.completed`. *Not schema-bumped* |
## MachineSetting (embedded in ExerciseDocument and WorkoutLogDocument)
A single free-form comfort setting for a machine-based exercise. `value` is a
string, not a number, because machine dials aren't uniformly numeric.
| Property | Type | Optional | Notes |
|---|---|---|---|
| `name` | String | | e.g. "Seat Height", "Back Rest Incline" |
| `value` | String | | Free-form, e.g. "4", "3rd hole", "45°" |
## WorkoutMetrics (embedded in WorkoutDocument, schema v2)
| Property | Type | Optional | Notes |
|---|---|---|---|
| `activeEnergyKcal` | Double | ✓ | |
| `avgHeartRate` | Double | ✓ | bpm; watch sessions only |
| `maxHeartRate` | Double | ✓ | bpm; watch sessions only |
| `minHeartRate` | Double | ✓ | bpm; watch sessions only |
| `totalVolume` | Double | ✓ | Σ sets×reps×weight across weighted logs |
| `hrZoneSeconds` | [Double] | ✓ | Seconds in each of 5 HR zones, low→high |
| `healthKitWorkoutUUID` | String | ✓ | Presence is the phone/watch Health-write dedupe signal |
| `source` | String | | Raw `MetricSource` |
| `recordedAt` | Date | | |
## Tombstone (IndieSync, minimal stub)
A delete writes a stub to `Stubs/…`, then removes the live file. A **full stub**
is a verbatim copy of the record's JSON plus an injected `deletedAt`; the
**minimal stub** below is the fallback when the source is unreadable. Stubs are
pruned after a 30-day grace period, except starter-seed stubs (exempt, and they
veto seed resurrection forever).
| Property | Type | Optional | Notes |
|---|---|---|---|
| `id` | String | | The deleted record's ULID |
| `deletedAt` | Date | | |
| `kind` | String | ✓ | App-defined category |
---
## Enum raw values
**WorkoutStatus** (String — on `WorkoutDocument.status`, `WorkoutLogDocument.status`):
`notStarted`, `inProgress`, `completed`, `skipped`
**LoadType** (Int — on `ExerciseDocument.loadType`, `WorkoutLogDocument.loadType`):
`0` none · `1` weight · `2` duration
**WorkoutActivityType** (Int — on `SplitDocument.activityType`):
`0` traditionalStrength · `1` functionalStrength · `2` hiit · `3` coreTraining · `4` cardio · `5` cycling
**MetricSource** (String — on `WorkoutMetrics.source`):
`watch`, `phoneEstimate`
**WeightUnit** (`lb`/`kg`) is a display setting only — it is never persisted in
documents, and stored weight integers are never rewritten when it changes.
+24 -18
View File
@@ -38,6 +38,11 @@ func deterministicULID(label: String) -> String {
// MARK: - Document shapes (field names must mirror Shared/Model/Documents.swift) // MARK: - Document shapes (field names must mirror Shared/Model/Documents.swift)
struct MachineSetting: Codable {
var name: String
var value: String
}
struct ExerciseDocument: Codable { struct ExerciseDocument: Codable {
var id: String var id: String
var name: String var name: String
@@ -47,9 +52,7 @@ struct ExerciseDocument: Codable {
var weight: Int var weight: Int
var loadType: Int var loadType: Int
var durationSeconds: Int var durationSeconds: Int
var weightLastUpdated: Date? var machineSettings: [MachineSetting]? = nil
var weightReminderWeeks: Int
var category: Int?
} }
struct SplitDocument: Codable { struct SplitDocument: Codable {
@@ -67,30 +70,34 @@ struct SplitDocument: Codable {
// MARK: - Seed data (canonical source for Workouts/Resources/StarterSplits/*.json) // MARK: - Seed data (canonical source for Workouts/Resources/StarterSplits/*.json)
struct Ex { let name: String; var weight = 0; var sets = 4; var reps = 10; var load = 1; var dur = 0; var cat = 0 } struct Ex { let name: String; var weight = 0; var sets = 4; var reps = 10; var load = 1; var dur = 0 }
struct Sp { let name: String; let color: String; let icon: String; let activity: Int; let ex: [Ex] } struct Sp { let name: String; let color: String; let icon: String; let activity: Int; let ex: [Ex] }
let seeds: [Sp] = [ let seeds: [Sp] = [
Sp(name: "Upper Body", color: "blue", icon: "figure.strengthtraining.traditional", activity: 0, ex: [ Sp(name: "Upper Body", color: "blue", icon: "figure.strengthtraining.traditional", activity: 0, ex: [
Ex(name: "Lat Pull Down", weight: 110), Ex(name: "Lat Pull Down", weight: 110),
Ex(name: "Tricep Press", weight: 100),
Ex(name: "Chest Press", weight: 40),
Ex(name: "Seated Row", weight: 90), Ex(name: "Seated Row", weight: 90),
Ex(name: "Shoulder Press", weight: 40),
Ex(name: "Chest Press", weight: 40),
Ex(name: "Tricep Press", weight: 100),
Ex(name: "Arm Curl", weight: 40),
]), ]),
Sp(name: "Core", color: "orange", icon: "figure.core.training", activity: 3, ex: [ Sp(name: "Core", color: "orange", icon: "figure.core.training", activity: 3, ex: [
Ex(name: "Abdominal", weight: 0), Ex(name: "Abdominal", weight: 40),
Ex(name: "Rotary", weight: 0), Ex(name: "Rotary", weight: 40),
]), ]),
Sp(name: "Lower Body", color: "green", icon: "figure.run", activity: 0, ex: [ Sp(name: "Lower Body", color: "green", icon: "figure.run", activity: 0, ex: [
Ex(name: "Abductor", weight: 140), Ex(name: "Leg Press", weight: 140),
Ex(name: "Adductor", weight: 140), Ex(name: "Leg Curl", weight: 40),
Ex(name: "Leg Press", weight: 160), Ex(name: "Leg Extension", weight: 80),
Ex(name: "Leg Curl", weight: 70), Ex(name: "Abductor", weight: 130),
Ex(name: "Adductor", weight: 130),
Ex(name: "Calfs", weight: 100),
]), ]),
Sp(name: "Bodyweight Core", color: "teal", icon: "figure.core.training", activity: 3, ex: [ Sp(name: "Bodyweight Core", color: "teal", icon: "figure.core.training", activity: 3, ex: [
Ex(name: "Cat-Cow", sets: 1, reps: 10, load: 0, cat: 1), Ex(name: "Cat-Cow", sets: 1, reps: 10, load: 0),
Ex(name: "Dead Bug", sets: 1, reps: 8, load: 0, cat: 1), Ex(name: "Dead Bug", sets: 1, reps: 8, load: 0),
Ex(name: "Bird Dog", sets: 1, reps: 8, load: 0, cat: 1), Ex(name: "Bird Dog", sets: 1, reps: 8, load: 0),
Ex(name: "Plank", sets: 3, reps: 0, load: 2, dur: 45), Ex(name: "Plank", sets: 3, reps: 0, load: 2, dur: 45),
Ex(name: "Hollow Body Hold", sets: 3, reps: 0, load: 2, dur: 30), Ex(name: "Hollow Body Hold", sets: 3, reps: 0, load: 2, dur: 30),
Ex(name: "Side Plank", sets: 3, reps: 0, load: 2, dur: 30), Ex(name: "Side Plank", sets: 3, reps: 0, load: 2, dur: 30),
@@ -113,7 +120,7 @@ try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories:
for (order, sp) in seeds.enumerated() { for (order, sp) in seeds.enumerated() {
let slug = sp.name.lowercased().replacingOccurrences(of: " ", with: "-") let slug = sp.name.lowercased().replacingOccurrences(of: " ", with: "-")
let doc = SplitDocument( let doc = SplitDocument(
schemaVersion: 1, schemaVersion: 2,
id: deterministicULID(label: "starter.\(slug)"), id: deterministicULID(label: "starter.\(slug)"),
name: sp.name, color: sp.color, systemImage: sp.icon, order: order, name: sp.name, color: sp.color, systemImage: sp.icon, order: order,
createdAt: fixedDate, updatedAt: fixedDate, createdAt: fixedDate, updatedAt: fixedDate,
@@ -121,8 +128,7 @@ for (order, sp) in seeds.enumerated() {
ExerciseDocument( ExerciseDocument(
id: deterministicULID(label: "starter.\(slug).ex\(idx)"), id: deterministicULID(label: "starter.\(slug).ex\(idx)"),
name: e.name, order: idx, sets: e.sets, reps: e.reps, weight: e.weight, name: e.name, order: idx, sets: e.sets, reps: e.reps, weight: e.weight,
loadType: e.load, durationSeconds: e.dur, weightLastUpdated: nil, loadType: e.load, durationSeconds: e.dur)
weightReminderWeeks: 2, category: e.cat)
}, },
activityType: sp.activity) activityType: sp.activity)
let data = try encoder.encode(doc) let data = try encoder.encode(doc)
+80 -11
View File
@@ -30,7 +30,12 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
/// quarantining the user's whole routine. /// quarantining the user's whole routine.
var activityType: Int? var activityType: Int?
static let currentSchemaVersion = 1 // Bumped 12 when the weight-reminder fields (`weightLastUpdated`,
// `weightReminderWeeks`) and `category` were removed and `machineSettings` was
// added. Removing required fields means older apps can no longer decode new
// files, so the forward-gate must quarantine them rather than let an older app
// silently rewrite them.
static let currentSchemaVersion = 2
var relativePath: String { "Splits/\(id).json" } var relativePath: String { "Splits/\(id).json" }
} }
@@ -44,12 +49,19 @@ struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable {
var weight: Int var weight: Int
var loadType: Int var loadType: Int
var durationSeconds: Int // total seconds (0 when not a timed exercise) var durationSeconds: Int // total seconds (0 when not a timed exercise)
var weightLastUpdated: Date? /// Ordered, user-defined comfort settings for machine-based exercises (seat
var weightReminderWeeks: Int /// height, back rest incline, pin position). `nil` not a machine exercise;
/// Raw `ExerciseCategory`; nil main circuit. Like `activityType`, deliberately /// empty a machine exercise with nothing recorded yet. This optionality
/// NOT schema-bumped: it only affects how the split screen groups its list, so an /// doubles as the machine-based flag there is no separate Bool.
/// older app dropping it on rewrite beats quarantining the routine. var machineSettings: [MachineSetting]? = nil
var category: Int? }
/// A single free-form comfort setting for a machine-based exercise. `value` is a
/// string, not a number, because machine dials aren't uniformly numeric ("3rd
/// hole", "45°"). Ordered within `ExerciseDocument.machineSettings`.
struct MachineSetting: Codable, Sendable, Equatable {
var name: String // "Seat Height", "Back Rest Incline"
var value: String // "4", "3rd hole", "45°" free-form; machine dials aren't uniformly numeric
} }
// MARK: - Workout // MARK: - Workout
@@ -74,7 +86,11 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
// Bumped 12 when `metrics` was added: the captured HR/calorie data is // Bumped 12 when `metrics` was added: the captured HR/calorie data is
// irreplaceable, so the forward-gate must quarantine these files on older apps // irreplaceable, so the forward-gate must quarantine these files on older apps
// rather than let them strip `metrics` on rewrite. // rather than let them strip `metrics` on rewrite.
static let currentSchemaVersion = 2 // Bumped 23 when the derived `completed` flag was removed from
// `WorkoutLogDocument` and `machineSettings` was added to it. Dropping a
// required field means older apps can no longer decode new files, so the
// forward-gate must quarantine them.
static let currentSchemaVersion = 3
var relativePath: String { Self.relativePath(id: id, start: start) } var relativePath: String { Self.relativePath(id: id, start: start) }
@@ -119,8 +135,7 @@ extension WorkoutDocument {
/// start-a-new-split prompt. /// start-a-new-split prompt.
mutating func endKeepingProgress() { mutating func endKeepingProgress() {
for i in logs.indices where (WorkoutStatus(rawValue: logs[i].status) ?? .notStarted) != .completed { for i in logs.indices where (WorkoutStatus(rawValue: logs[i].status) ?? .notStarted) != .completed {
logs[i].status = WorkoutStatus.skipped.rawValue logs[i].transition(to: .skipped)
logs[i].completed = false
} }
recomputeStatusFromLogs() recomputeStatusFromLogs()
updatedAt = Date() updatedAt = Date()
@@ -137,10 +152,64 @@ struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
var loadType: Int var loadType: Int
var durationSeconds: Int // total seconds (0 when not a timed exercise) var durationSeconds: Int // total seconds (0 when not a timed exercise)
var currentStateIndex: Int var currentStateIndex: Int
var completed: Bool
var status: String // WorkoutStatus raw value var status: String // WorkoutStatus raw value
var notes: String? var notes: String?
var date: Date var date: Date
/// Snapshot of the exercise's machine comfort settings at plan time (like the
/// sets/reps/weight snapshot). `nil` not a machine exercise; empty a machine
/// exercise with nothing recorded yet.
var machineSettings: [MachineSetting]? = nil
/// When the log first moved to `.inProgress` / last moved to `.completed`.
/// Optional additions absent in files written before they existed.
var startedAt: Date? = nil
var completedAt: Date? = nil
}
extension WorkoutLogDocument {
/// Build a fresh plan-time log from a split's exercise. Snapshots the plan
/// fields (sets/reps/weight/duration) *and* the machine comfort settings, so a
/// running workout carries the exercise's settings as its own editable copy
/// `nil` stays `nil` (not a machine exercise), and `[]` stays `[]` (machine
/// exercise, nothing recorded yet). Every "start a workout" / "add exercise to
/// a workout" path funnels through here so the snapshot can't drift per site.
init(planFrom exercise: ExerciseDocument, order: Int, date: Date) {
self.init(
id: ULID.make(),
exerciseName: exercise.name,
order: order,
sets: exercise.sets,
reps: exercise.reps,
weight: exercise.weight,
loadType: exercise.loadType,
durationSeconds: exercise.durationSeconds,
currentStateIndex: 0,
status: WorkoutStatus.notStarted.rawValue,
notes: nil,
date: date,
machineSettings: exercise.machineSettings
)
}
/// Move to a new status, keeping the started/completed timestamps consistent.
/// Every screen that flips a log's status (phone and watch) goes through here,
/// so the timestamps can't drift per-call-site. Completion is derived from
/// `status` (`completed == (status == WorkoutStatus.completed.rawValue)`), so
/// there's no separate flag to keep in sync.
mutating func transition(to newStatus: WorkoutStatus) {
status = newStatus.rawValue
switch newStatus {
case .notStarted:
startedAt = nil
completedAt = nil
case .inProgress:
if startedAt == nil { startedAt = Date() }
completedAt = nil
case .completed:
if completedAt == nil { completedAt = Date() }
case .skipped:
completedAt = nil
}
}
} }
// MARK: - Workout health metrics // MARK: - Workout health metrics
+19 -17
View File
@@ -62,15 +62,16 @@ final class Exercise {
var weight: Int = 0 var weight: Int = 0
var loadType: Int = LoadType.weight.rawValue var loadType: Int = LoadType.weight.rawValue
var durationTotalSeconds: Int = 0 var durationTotalSeconds: Int = 0
var weightLastUpdated: Date? // Machine comfort settings stored directly as an optional Codable array
var weightReminderTimeIntervalWeeks: Int = 2 // (SwiftData persists the `[MachineSetting]` composite), mirroring how
var categoryRaw: Int = ExerciseCategory.main.rawValue // `Workout` stores `hrZoneSeconds` as a plain `[Double]?`. `nil` not a
// machine exercise; empty a machine exercise with nothing recorded yet.
var machineSettings: [MachineSetting]?
var split: Split? var split: Split?
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Int, init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Int,
loadType: Int, durationTotalSeconds: Int, weightLastUpdated: Date?, loadType: Int, durationTotalSeconds: Int, machineSettings: [MachineSetting]? = nil) {
weightReminderTimeIntervalWeeks: Int, categoryRaw: Int = ExerciseCategory.main.rawValue) {
self.id = id self.id = id
self.name = name self.name = name
self.order = order self.order = order
@@ -79,9 +80,7 @@ final class Exercise {
self.weight = weight self.weight = weight
self.loadType = loadType self.loadType = loadType
self.durationTotalSeconds = durationTotalSeconds self.durationTotalSeconds = durationTotalSeconds
self.weightLastUpdated = weightLastUpdated self.machineSettings = machineSettings
self.weightReminderTimeIntervalWeeks = weightReminderTimeIntervalWeeks
self.categoryRaw = categoryRaw
} }
var loadTypeEnum: LoadType { var loadTypeEnum: LoadType {
@@ -89,11 +88,6 @@ final class Exercise {
set { loadType = newValue.rawValue } set { loadType = newValue.rawValue }
} }
var categoryEnum: ExerciseCategory {
get { ExerciseCategory(rawValue: categoryRaw) ?? .main }
set { categoryRaw = newValue.rawValue }
}
/// One-line plan summary for list rows duration exercises show their hold time /// One-line plan summary for list rows duration exercises show their hold time
/// ("3 × 45 sec"), unloaded ones drop the weight ("3 × 12 reps"), weighted ones /// ("3 × 45 sec"), unloaded ones drop the weight ("3 × 12 reps"), weighted ones
/// keep the full "3 × 10 × 40 lb". /// keep the full "3 × 10 × 40 lb".
@@ -233,16 +227,22 @@ final class WorkoutLog {
var loadType: Int = LoadType.weight.rawValue var loadType: Int = LoadType.weight.rawValue
var durationTotalSeconds: Int = 0 var durationTotalSeconds: Int = 0
var currentStateIndex: Int = 0 var currentStateIndex: Int = 0
var completed: Bool = false
var statusRaw: String = WorkoutStatus.notStarted.rawValue var statusRaw: String = WorkoutStatus.notStarted.rawValue
var notes: String? var notes: String?
var date: Date = Date() var date: Date = Date()
// Snapshot of the exercise's machine comfort settings at plan time stored
// directly as an optional Codable array, same pattern as `Exercise`.
var machineSettings: [MachineSetting]?
var startedAt: Date?
var completedAt: Date?
var workout: Workout? var workout: Workout?
init(id: String, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int, init(id: String, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int,
loadType: Int, durationTotalSeconds: Int, currentStateIndex: Int, completed: Bool, loadType: Int, durationTotalSeconds: Int, currentStateIndex: Int,
statusRaw: String, notes: String?, date: Date) { statusRaw: String, notes: String?, date: Date,
machineSettings: [MachineSetting]? = nil,
startedAt: Date? = nil, completedAt: Date? = nil) {
self.id = id self.id = id
self.exerciseName = exerciseName self.exerciseName = exerciseName
self.order = order self.order = order
@@ -252,10 +252,12 @@ final class WorkoutLog {
self.loadType = loadType self.loadType = loadType
self.durationTotalSeconds = durationTotalSeconds self.durationTotalSeconds = durationTotalSeconds
self.currentStateIndex = currentStateIndex self.currentStateIndex = currentStateIndex
self.completed = completed
self.statusRaw = statusRaw self.statusRaw = statusRaw
self.notes = notes self.notes = notes
self.date = date self.date = date
self.machineSettings = machineSettings
self.startedAt = startedAt
self.completedAt = completedAt
} }
var status: WorkoutStatus { var status: WorkoutStatus {
-18
View File
@@ -35,24 +35,6 @@ enum LoadType: Int, CaseIterable, Codable, Sendable {
} }
} }
/// Which segment of a split an exercise belongs to the split screen groups its
/// exercise list by this. Persisted as its raw `Int` value; `main` is the default
/// (and what older documents without the field decode to).
enum ExerciseCategory: Int, CaseIterable, Codable, Sendable {
case main = 0
case warmup = 1
/// Order the categories appear on screen: warm-up first, then the main circuit.
static let displayOrder: [ExerciseCategory] = [.warmup, .main]
var displayName: String {
switch self {
case .main: "Main Circuit"
case .warmup: "Warm-up"
}
}
}
/// The kind of training a split represents used to tag the Apple Health workout /// The kind of training a split represents used to tag the Apple Health workout
/// so it lands in the right category (and credits the rings correctly), and to pick /// so it lands in the right category (and credits the rings correctly), and to pick
/// a MET value when the phone has to estimate calories without watch sensor data. /// a MET value when the phone has to estimate calories without watch sensor data.
+12 -13
View File
@@ -17,9 +17,7 @@ extension ExerciseDocument {
init(from e: Exercise) { init(from e: Exercise) {
self.init(id: e.id, name: e.name, order: e.order, sets: e.sets, reps: e.reps, self.init(id: e.id, name: e.name, order: e.order, sets: e.sets, reps: e.reps,
weight: e.weight, loadType: e.loadType, durationSeconds: e.durationTotalSeconds, weight: e.weight, loadType: e.loadType, durationSeconds: e.durationTotalSeconds,
weightLastUpdated: e.weightLastUpdated, machineSettings: e.machineSettings)
weightReminderWeeks: e.weightReminderTimeIntervalWeeks,
category: e.categoryRaw)
} }
} }
@@ -38,7 +36,9 @@ extension WorkoutLogDocument {
self.init(id: log.id, exerciseName: log.exerciseName, order: log.order, sets: log.sets, self.init(id: log.id, exerciseName: log.exerciseName, order: log.order, sets: log.sets,
reps: log.reps, weight: log.weight, loadType: log.loadType, reps: log.reps, weight: log.weight, loadType: log.loadType,
durationSeconds: log.durationTotalSeconds, currentStateIndex: log.currentStateIndex, durationSeconds: log.durationTotalSeconds, currentStateIndex: log.currentStateIndex,
completed: log.completed, status: log.statusRaw, notes: log.notes, date: log.date) status: log.statusRaw, notes: log.notes, date: log.date,
machineSettings: log.machineSettings,
startedAt: log.startedAt, completedAt: log.completedAt)
} }
} }
@@ -108,9 +108,7 @@ enum CacheMapper {
private static func makeExercise(_ d: ExerciseDocument) -> Exercise { private static func makeExercise(_ d: ExerciseDocument) -> Exercise {
Exercise(id: d.id, name: d.name, order: d.order, sets: d.sets, reps: d.reps, weight: d.weight, Exercise(id: d.id, name: d.name, order: d.order, sets: d.sets, reps: d.reps, weight: d.weight,
loadType: d.loadType, durationTotalSeconds: d.durationSeconds, loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
weightLastUpdated: d.weightLastUpdated, machineSettings: d.machineSettings)
weightReminderTimeIntervalWeeks: d.weightReminderWeeks,
categoryRaw: d.category ?? ExerciseCategory.main.rawValue)
} }
private static func apply(_ d: ExerciseDocument, to e: Exercise) { private static func apply(_ d: ExerciseDocument, to e: Exercise) {
@@ -121,9 +119,7 @@ enum CacheMapper {
e.weight = d.weight e.weight = d.weight
e.loadType = d.loadType e.loadType = d.loadType
e.durationTotalSeconds = d.durationSeconds e.durationTotalSeconds = d.durationSeconds
e.weightLastUpdated = d.weightLastUpdated e.machineSettings = d.machineSettings
e.weightReminderTimeIntervalWeeks = d.weightReminderWeeks
e.categoryRaw = d.category ?? ExerciseCategory.main.rawValue
} }
// MARK: Workout // MARK: Workout
@@ -169,8 +165,9 @@ enum CacheMapper {
private static func makeLog(_ d: WorkoutLogDocument) -> WorkoutLog { private static func makeLog(_ d: WorkoutLogDocument) -> WorkoutLog {
WorkoutLog(id: d.id, exerciseName: d.exerciseName, order: d.order, sets: d.sets, reps: d.reps, WorkoutLog(id: d.id, exerciseName: d.exerciseName, order: d.order, sets: d.sets, reps: d.reps,
weight: d.weight, loadType: d.loadType, durationTotalSeconds: d.durationSeconds, weight: d.weight, loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
currentStateIndex: d.currentStateIndex, completed: d.completed, statusRaw: d.status, currentStateIndex: d.currentStateIndex, statusRaw: d.status,
notes: d.notes, date: d.date) notes: d.notes, date: d.date, machineSettings: d.machineSettings,
startedAt: d.startedAt, completedAt: d.completedAt)
} }
private static func apply(_ d: WorkoutLogDocument, to l: WorkoutLog) { private static func apply(_ d: WorkoutLogDocument, to l: WorkoutLog) {
@@ -182,9 +179,11 @@ enum CacheMapper {
l.loadType = d.loadType l.loadType = d.loadType
l.durationTotalSeconds = d.durationSeconds l.durationTotalSeconds = d.durationSeconds
l.currentStateIndex = d.currentStateIndex l.currentStateIndex = d.currentStateIndex
l.completed = d.completed
l.statusRaw = d.status l.statusRaw = d.status
l.notes = d.notes l.notes = d.notes
l.date = d.date l.date = d.date
l.machineSettings = d.machineSettings
l.startedAt = d.startedAt
l.completedAt = d.completedAt
} }
} }
@@ -8,7 +8,9 @@ enum WorkoutsModelContainer {
/// Bump whenever the cache schema changes wipes and rebuilds from files. /// Bump whenever the cache schema changes wipes and rebuilds from files.
/// 2: added health-metric columns to `Workout` and `activityTypeRaw` to `Split`. /// 2: added health-metric columns to `Workout` and `activityTypeRaw` to `Split`.
/// 3: added `categoryRaw` to `Exercise`. /// 3: added `categoryRaw` to `Exercise`.
static let currentSchemaVersion = 3 /// 4: removed the weight-reminder columns and `categoryRaw` from `Exercise` and
/// the `completed` column from `WorkoutLog`; added `machineSettings` to both.
static let currentSchemaVersion = 4
private static let schemaVersionKey = "Workouts.persistenceSchemaVersion" private static let schemaVersionKey = "Workouts.persistenceSchemaVersion"
private static let identityTokenKey = "Workouts.iCloudIdentityToken" private static let identityTokenKey = "Workouts.iCloudIdentityToken"
+20 -19
View File
@@ -30,18 +30,20 @@ enum ScreenshotSeed {
func daysAgo(_ n: Int) -> Date { cal.date(byAdding: .day, value: -n, to: today) ?? today } func daysAgo(_ n: Int) -> Date { cal.date(byAdding: .day, value: -n, to: today) ?? today }
// ---- Splits (with exercises) ------------------------------------------ // ---- Splits (with exercises) ------------------------------------------
// Names match the bundled Exercise Library rigs so every screen shows its
// animated figure (an unknown name would leave the figure slot empty).
let splits: [(String, String, String, [(String, Int, Int, Int)])] = [ let splits: [(String, String, String, [(String, Int, Int, Int)])] = [
("Upper Body", "purple", "dumbbell.fill", [ ("Upper Body", "blue", "figure.strengthtraining.traditional", [
("Bench Press", 4, 10, 135), ("Overhead Press", 4, 10, 75), ("Lat Pull Down", 4, 10, 110), ("Seated Row", 4, 10, 90),
("Lat Pulldown", 4, 12, 120), ("Bicep Curl", 3, 12, 30), ("Shoulder Press", 4, 10, 40), ("Arm Curl", 3, 12, 40),
]), ]),
("Lower Body", "blue", "figure.strengthtraining.traditional", [ ("Lower Body", "green", "figure.run", [
("Back Squat", 5, 5, 185), ("Romanian Deadlift", 4, 8, 155), ("Leg Press", 4, 10, 140), ("Leg Curl", 4, 10, 40),
("Leg Press", 4, 12, 270), ("Calf Raise", 4, 15, 90), ("Leg Extension", 4, 10, 80), ("Calfs", 4, 15, 100),
]), ]),
("Core", "orange", "figure.core.training", [ ("Core", "orange", "figure.core.training", [
("Plank", 3, 0, 0), ("Hanging Leg Raise", 3, 12, 0), ("Plank", 3, 0, 0), ("Abdominal", 4, 10, 40),
("Cable Crunch", 3, 15, 50), ("Rotary", 4, 10, 40),
]), ]),
] ]
@@ -53,8 +55,7 @@ enum ScreenshotSeed {
let isDuration = e.0 == "Plank" let isDuration = e.0 == "Plank"
let ex = Exercise(id: ULID.make(), name: e.0, order: eIndex, sets: e.1, reps: e.2, let ex = Exercise(id: ULID.make(), name: e.0, order: eIndex, sets: e.1, reps: e.2,
weight: e.3, loadType: (isDuration ? LoadType.duration : .weight).rawValue, weight: e.3, loadType: (isDuration ? LoadType.duration : .weight).rawValue,
durationTotalSeconds: isDuration ? 45 : 0, weightLastUpdated: today, durationTotalSeconds: isDuration ? 45 : 0)
weightReminderTimeIntervalWeeks: 2)
ex.split = split ex.split = split
context.insert(ex) context.insert(ex)
} }
@@ -62,19 +63,19 @@ enum ScreenshotSeed {
let upper = splits[0] let upper = splits[0]
// ---- Past finished sessions (Bench Press trend for the chart) ---------- // ---- Past finished sessions (Lat Pull Down trend for the chart) --------
let benchTrend = [115, 120, 125, 130] let liftTrend = [95, 100, 105, 110]
for (i, w) in benchTrend.enumerated() { for (i, w) in liftTrend.enumerated() {
let date = daysAgo((benchTrend.count - i) * 4) let date = daysAgo((liftTrend.count - i) * 4)
let workout = Workout(id: ULID.make(), splitID: nil, splitName: upper.0, start: date, let workout = Workout(id: ULID.make(), splitID: nil, splitName: upper.0, start: date,
end: date.addingTimeInterval(2400), statusRaw: WorkoutStatus.completed.rawValue, end: date.addingTimeInterval(2400), statusRaw: WorkoutStatus.completed.rawValue,
createdAt: date, updatedAt: date, jsonRelativePath: "") createdAt: date, updatedAt: date, jsonRelativePath: "")
context.insert(workout) context.insert(workout)
for (eIndex, e) in upper.3.enumerated() { for (eIndex, e) in upper.3.enumerated() {
let weight = e.0 == "Bench Press" ? w : e.3 let weight = e.0 == "Lat Pull Down" ? w : e.3
let log = WorkoutLog(id: ULID.make(), exerciseName: e.0, order: eIndex, sets: e.1, let log = WorkoutLog(id: ULID.make(), exerciseName: e.0, order: eIndex, sets: e.1,
reps: e.2, weight: weight, loadType: LoadType.weight.rawValue, reps: e.2, weight: weight, loadType: LoadType.weight.rawValue,
durationTotalSeconds: 0, currentStateIndex: e.1, completed: true, durationTotalSeconds: 0, currentStateIndex: e.1,
statusRaw: WorkoutStatus.completed.rawValue, notes: nil, date: date) statusRaw: WorkoutStatus.completed.rawValue, notes: nil, date: date)
log.workout = workout log.workout = workout
context.insert(log) context.insert(log)
@@ -86,13 +87,13 @@ enum ScreenshotSeed {
end: nil, statusRaw: WorkoutStatus.inProgress.rawValue, end: nil, statusRaw: WorkoutStatus.inProgress.rawValue,
createdAt: today, updatedAt: today, jsonRelativePath: "") createdAt: today, updatedAt: today, jsonRelativePath: "")
context.insert(workout) context.insert(workout)
// Bench Press done, Overhead Press partway, the rest to go. // Lat Pull Down done, Seated Row partway, the rest to go.
let progress = [(4, WorkoutStatus.completed), (2, .inProgress), (0, .notStarted), (0, .notStarted)] let progress = [(4, WorkoutStatus.completed), (2, .inProgress), (0, .notStarted), (0, .notStarted)]
for (eIndex, e) in upper.3.enumerated() { for (eIndex, e) in upper.3.enumerated() {
let (idx, status) = progress[eIndex] let (idx, status) = progress[eIndex]
let log = WorkoutLog(id: ULID.make(), exerciseName: e.0, order: eIndex, sets: e.1, reps: e.2, let log = WorkoutLog(id: ULID.make(), exerciseName: e.0, order: eIndex, sets: e.1, reps: e.2,
weight: e.0 == "Bench Press" ? 135 : e.3, loadType: LoadType.weight.rawValue, weight: e.0 == "Lat Pull Down" ? 110 : e.3, loadType: LoadType.weight.rawValue,
durationTotalSeconds: 0, currentStateIndex: idx, completed: status == .completed, durationTotalSeconds: 0, currentStateIndex: idx,
statusRaw: status.rawValue, notes: nil, date: today) statusRaw: status.rawValue, notes: nil, date: today)
log.workout = workout log.workout = workout
context.insert(log) context.insert(log)
+8
View File
@@ -18,8 +18,16 @@ extension Date {
private static let weekdayAbbrev: DateFormatter = { private static let weekdayAbbrev: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEE"; return f let f = DateFormatter(); f.dateFormat = "EEE"; return f
}() }()
private static let relativeDateTime: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium; f.timeStyle = .short
f.doesRelativeDateFormatting = true
return f
}()
func formattedDate() -> String { Self.shortDateTime.string(from: self) } func formattedDate() -> String { Self.shortDateTime.string(from: self) }
/// "Today at 10:44 AM" / "Yesterday at 9:12 AM" / "Jul 3, 2026 at 8:00 AM".
func formattedRelativeDateTime() -> String { Self.relativeDateTime.string(from: self) }
func formattedTime() -> String { Self.timeOnly.string(from: self) } func formattedTime() -> String { Self.timeOnly.string(from: self) }
func formatDate() -> String { Self.mediumDate.string(from: self) } func formatDate() -> String { Self.mediumDate.string(from: self) }
+23
View File
@@ -0,0 +1,23 @@
# TODO
Schema work pending (being designed in a separate session) — the workout exercise
list (`WorkoutLogListView`) has UI already waiting on these:
- **Bodyweight indicator**: the row hides weight when `loadType != .weight`, treating
`LoadType.none` as "bodyweight". Decide whether that's the durable semantic or whether
exercises need an explicit `.bodyweight` load type / flag (e.g. weighted pull-ups would
need weight *and* bodyweight semantics).
- **Machine comfort settings** (iOS UI built): `machineSettings: [MachineSetting]?`
exists on both `ExerciseDocument` and `WorkoutLogDocument` (plus the cache entities
and mappers). The optional doubles as the machine-based flag — nil means "not a
machine exercise", empty means "machine exercise, nothing recorded yet". Built on
iOS: the exercise add/edit screen's Machine section (toggle + ordered
reorderable/deletable settings via the shared `MachineSettingsEditor`), the
plan-time snapshot from exercise → log at workout start / add-exercise, and the
in-workout row's Settings affordance (shown for any machine-based log) opening an
editor sheet that saves to the log *and* writes back to the split's exercise as the
durable default (following the seed clone-on-edit redirect).
Remaining: **watch-side display** — the watch app doesn't yet surface or edit
machine settings, and its own "add exercise to a running workout" path
(`Workouts Watch App/Views/WorkoutLogListView.swift`) doesn't snapshot
`machineSettings` into the new log.
@@ -189,13 +189,20 @@ struct ExerciseProgressView: View {
var body: some View { var body: some View {
if startsCompleted { if startsCompleted {
// Same top/bottom split as the run flow, so the form-guide figure
// stays on screen (matching the iPhone counterpart).
VStack(spacing: 0) {
CompletedPhaseView() CompletedPhaseView()
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
}
} else { } else {
flowBody flowBody
} }
} }
private var flowBody: some View { private var flowBody: some View {
VStack(spacing: 0) {
// Paged flow top half.
TabView(selection: $currentPage) { TabView(selection: $currentPage) {
ForEach(0..<totalPages, id: \.self) { index in ForEach(0..<totalPages, id: \.self) { index in
page(for: index) page(for: index)
@@ -203,12 +210,18 @@ struct ExerciseProgressView: View {
} }
} }
.tabViewStyle(.page(indexDisplayMode: .never)) .tabViewStyle(.page(indexDisplayMode: .never))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
if let dots = workDots { if let dots = workDots {
WorkPhaseDots(model: dots) WorkPhaseDots(model: dots)
.padding(.bottom, 2) .padding(.bottom, 2)
} }
} }
// Bottom half: the looping form-guide figure when a bundled motion
// matches this exercise; empty space otherwise.
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
}
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button { Button {
@@ -464,7 +477,7 @@ struct ExerciseProgressView: View {
private func beginExercise() { private func beginExercise() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
guard doc.logs[i].status == WorkoutStatus.notStarted.rawValue 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() recomputeWorkoutStatus()
doc.updatedAt = Date() doc.updatedAt = Date()
onChange() onChange()
@@ -478,8 +491,7 @@ struct ExerciseProgressView: View {
if let i = doc.logs.firstIndex(where: { $0.id == logID }) { if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
doc.logs[i].sets = newCount doc.logs[i].sets = newCount
doc.logs[i].currentStateIndex = newCount - 1 // every prior set is now complete doc.logs[i].currentStateIndex = newCount - 1 // every prior set is now complete
doc.logs[i].status = WorkoutStatus.inProgress.rawValue doc.logs[i].transition(to: .inProgress)
doc.logs[i].completed = false
recomputeWorkoutStatus() recomputeWorkoutStatus()
doc.updatedAt = Date() doc.updatedAt = Date()
onChange() onChange()
@@ -506,8 +518,7 @@ struct ExerciseProgressView: View {
guard reached > doc.logs[i].currentStateIndex else { return } guard reached > doc.logs[i].currentStateIndex else { return }
doc.logs[i].currentStateIndex = reached doc.logs[i].currentStateIndex = reached
doc.logs[i].status = WorkoutStatus.inProgress.rawValue doc.logs[i].transition(to: .inProgress)
doc.logs[i].completed = false
recomputeWorkoutStatus() recomputeWorkoutStatus()
doc.updatedAt = Date() doc.updatedAt = Date()
@@ -520,11 +531,9 @@ struct ExerciseProgressView: View {
let log = doc.logs[i] let log = doc.logs[i]
// Skip the write if it's already pristine (e.g. landing on Ready before any set). // Skip the write if it's already pristine (e.g. landing on Ready before any set).
guard log.currentStateIndex != 0 guard log.currentStateIndex != 0
|| log.status != WorkoutStatus.notStarted.rawValue || log.status != WorkoutStatus.notStarted.rawValue else { return }
|| log.completed else { return }
doc.logs[i].currentStateIndex = 0 doc.logs[i].currentStateIndex = 0
doc.logs[i].status = WorkoutStatus.notStarted.rawValue doc.logs[i].transition(to: .notStarted)
doc.logs[i].completed = false
recomputeWorkoutStatus() recomputeWorkoutStatus()
doc.updatedAt = Date() doc.updatedAt = Date()
onChange() onChange()
@@ -533,8 +542,7 @@ struct ExerciseProgressView: View {
private func completeExercise() { private func completeExercise() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].currentStateIndex = setCount doc.logs[i].currentStateIndex = setCount
doc.logs[i].status = WorkoutStatus.completed.rawValue doc.logs[i].transition(to: .completed)
doc.logs[i].completed = true
recomputeWorkoutStatus() recomputeWorkoutStatus()
doc.updatedAt = Date() doc.updatedAt = Date()
@@ -65,7 +65,10 @@ struct WorkoutLogListView: View {
} }
} }
if doc.splitID != nil { // Gate on the *resolved* split, not the stale `splitID` string a split
// deleted on the phone leaves the id dangling, and offering Add Exercise
// against it would only show a misleading "All exercises added" picker.
if split != nil {
Section { Section {
Button { Button {
showingExercisePicker = true showingExercisePicker = true
@@ -84,7 +87,7 @@ struct WorkoutLogListView: View {
ContentUnavailableView( ContentUnavailableView(
"No Exercises", "No Exercises",
systemImage: "figure.strengthtraining.traditional", systemImage: "figure.strengthtraining.traditional",
description: Text(doc.splitID == nil description: Text(split == nil
? "No exercises in this workout." ? "No exercises in this workout."
: "Tap + to add exercises.") : "Tap + to add exercises.")
) )
@@ -135,7 +138,6 @@ struct WorkoutLogListView: View {
loadType: exercise.loadType, loadType: exercise.loadType,
durationSeconds: exercise.durationTotalSeconds, durationSeconds: exercise.durationTotalSeconds,
currentStateIndex: 0, currentStateIndex: 0,
completed: false,
status: WorkoutStatus.notStarted.rawValue, status: WorkoutStatus.notStarted.rawValue,
notes: nil, notes: nil,
date: doc.start date: doc.start
@@ -4,7 +4,6 @@
"createdAt" : "2020-01-01T00:00:00Z", "createdAt" : "2020-01-01T00:00:00Z",
"exercises" : [ "exercises" : [
{ {
"category" : 1,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT00AHY49HPHA00JGDQY", "id" : "01DXF6DT00AHY49HPHA00JGDQY",
"loadType" : 0, "loadType" : 0,
@@ -12,11 +11,9 @@
"order" : 0, "order" : 0,
"reps" : 10, "reps" : 10,
"sets" : 1, "sets" : 1,
"weight" : 0, "weight" : 0
"weightReminderWeeks" : 2
}, },
{ {
"category" : 1,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT0021S72PRVXDA0HCNB", "id" : "01DXF6DT0021S72PRVXDA0HCNB",
"loadType" : 0, "loadType" : 0,
@@ -24,11 +21,9 @@
"order" : 1, "order" : 1,
"reps" : 8, "reps" : 8,
"sets" : 1, "sets" : 1,
"weight" : 0, "weight" : 0
"weightReminderWeeks" : 2
}, },
{ {
"category" : 1,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT00K2AHTEHR3HRVKGEG", "id" : "01DXF6DT00K2AHTEHR3HRVKGEG",
"loadType" : 0, "loadType" : 0,
@@ -36,11 +31,9 @@
"order" : 2, "order" : 2,
"reps" : 8, "reps" : 8,
"sets" : 1, "sets" : 1,
"weight" : 0, "weight" : 0
"weightReminderWeeks" : 2
}, },
{ {
"category" : 0,
"durationSeconds" : 45, "durationSeconds" : 45,
"id" : "01DXF6DT00V256F3TPS99MCP5F", "id" : "01DXF6DT00V256F3TPS99MCP5F",
"loadType" : 2, "loadType" : 2,
@@ -48,11 +41,9 @@
"order" : 3, "order" : 3,
"reps" : 0, "reps" : 0,
"sets" : 3, "sets" : 3,
"weight" : 0, "weight" : 0
"weightReminderWeeks" : 2
}, },
{ {
"category" : 0,
"durationSeconds" : 30, "durationSeconds" : 30,
"id" : "01DXF6DT007YMYCAYYNJEFWTQ5", "id" : "01DXF6DT007YMYCAYYNJEFWTQ5",
"loadType" : 2, "loadType" : 2,
@@ -60,11 +51,9 @@
"order" : 4, "order" : 4,
"reps" : 0, "reps" : 0,
"sets" : 3, "sets" : 3,
"weight" : 0, "weight" : 0
"weightReminderWeeks" : 2
}, },
{ {
"category" : 0,
"durationSeconds" : 30, "durationSeconds" : 30,
"id" : "01DXF6DT00WXCASZG1VEZRPT0Y", "id" : "01DXF6DT00WXCASZG1VEZRPT0Y",
"loadType" : 2, "loadType" : 2,
@@ -72,11 +61,9 @@
"order" : 5, "order" : 5,
"reps" : 0, "reps" : 0,
"sets" : 3, "sets" : 3,
"weight" : 0, "weight" : 0
"weightReminderWeeks" : 2
}, },
{ {
"category" : 0,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT00VMWCDF9QCB28WR20", "id" : "01DXF6DT00VMWCDF9QCB28WR20",
"loadType" : 0, "loadType" : 0,
@@ -84,11 +71,9 @@
"order" : 6, "order" : 6,
"reps" : 12, "reps" : 12,
"sets" : 3, "sets" : 3,
"weight" : 0, "weight" : 0
"weightReminderWeeks" : 2
}, },
{ {
"category" : 0,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT00MJH9N622QTMM7XB3", "id" : "01DXF6DT00MJH9N622QTMM7XB3",
"loadType" : 0, "loadType" : 0,
@@ -96,11 +81,9 @@
"order" : 7, "order" : 7,
"reps" : 8, "reps" : 8,
"sets" : 3, "sets" : 3,
"weight" : 0, "weight" : 0
"weightReminderWeeks" : 2
}, },
{ {
"category" : 0,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT00Z98NR23ESEEKGQH1", "id" : "01DXF6DT00Z98NR23ESEEKGQH1",
"loadType" : 0, "loadType" : 0,
@@ -108,14 +91,13 @@
"order" : 8, "order" : 8,
"reps" : 12, "reps" : 12,
"sets" : 3, "sets" : 3,
"weight" : 0, "weight" : 0
"weightReminderWeeks" : 2
} }
], ],
"id" : "01DXF6DT00RV99WG172YFW4NKA", "id" : "01DXF6DT00RV99WG172YFW4NKA",
"name" : "Bodyweight Core", "name" : "Bodyweight Core",
"order" : 3, "order" : 3,
"schemaVersion" : 1, "schemaVersion" : 2,
"systemImage" : "figure.core.training", "systemImage" : "figure.core.training",
"updatedAt" : "2020-01-01T00:00:00Z" "updatedAt" : "2020-01-01T00:00:00Z"
} }
@@ -4,7 +4,6 @@
"createdAt" : "2020-01-01T00:00:00Z", "createdAt" : "2020-01-01T00:00:00Z",
"exercises" : [ "exercises" : [
{ {
"category" : 0,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT00C4K0NY1NRCFPB3XY", "id" : "01DXF6DT00C4K0NY1NRCFPB3XY",
"loadType" : 1, "loadType" : 1,
@@ -12,11 +11,9 @@
"order" : 0, "order" : 0,
"reps" : 10, "reps" : 10,
"sets" : 4, "sets" : 4,
"weight" : 0, "weight" : 40
"weightReminderWeeks" : 2
}, },
{ {
"category" : 0,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT001AY3SXTEYHA94MHZ", "id" : "01DXF6DT001AY3SXTEYHA94MHZ",
"loadType" : 1, "loadType" : 1,
@@ -24,14 +21,13 @@
"order" : 1, "order" : 1,
"reps" : 10, "reps" : 10,
"sets" : 4, "sets" : 4,
"weight" : 0, "weight" : 40
"weightReminderWeeks" : 2
} }
], ],
"id" : "01DXF6DT001MA0TM7FHJZT098Z", "id" : "01DXF6DT001MA0TM7FHJZT098Z",
"name" : "Core", "name" : "Core",
"order" : 1, "order" : 1,
"schemaVersion" : 1, "schemaVersion" : 2,
"systemImage" : "figure.core.training", "systemImage" : "figure.core.training",
"updatedAt" : "2020-01-01T00:00:00Z" "updatedAt" : "2020-01-01T00:00:00Z"
} }
@@ -4,58 +4,70 @@
"createdAt" : "2020-01-01T00:00:00Z", "createdAt" : "2020-01-01T00:00:00Z",
"exercises" : [ "exercises" : [
{ {
"category" : 0,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT00GV3336R9C25W4X2A", "id" : "01DXF6DT00GV3336R9C25W4X2A",
"loadType" : 1, "loadType" : 1,
"name" : "Abductor", "name" : "Leg Press",
"order" : 0, "order" : 0,
"reps" : 10, "reps" : 10,
"sets" : 4, "sets" : 4,
"weight" : 140, "weight" : 140
"weightReminderWeeks" : 2
}, },
{ {
"category" : 0,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT00Z28DJ7PSP7Q11CJH", "id" : "01DXF6DT00Z28DJ7PSP7Q11CJH",
"loadType" : 1, "loadType" : 1,
"name" : "Adductor", "name" : "Leg Curl",
"order" : 1, "order" : 1,
"reps" : 10, "reps" : 10,
"sets" : 4, "sets" : 4,
"weight" : 140, "weight" : 40
"weightReminderWeeks" : 2
}, },
{ {
"category" : 0,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT00D53Q4QMWAE1BHXR6", "id" : "01DXF6DT00D53Q4QMWAE1BHXR6",
"loadType" : 1, "loadType" : 1,
"name" : "Leg Press", "name" : "Leg Extension",
"order" : 2, "order" : 2,
"reps" : 10, "reps" : 10,
"sets" : 4, "sets" : 4,
"weight" : 160, "weight" : 80
"weightReminderWeeks" : 2
}, },
{ {
"category" : 0,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT006HW2KWNA5BCCDCJE", "id" : "01DXF6DT006HW2KWNA5BCCDCJE",
"loadType" : 1, "loadType" : 1,
"name" : "Leg Curl", "name" : "Abductor",
"order" : 3, "order" : 3,
"reps" : 10, "reps" : 10,
"sets" : 4, "sets" : 4,
"weight" : 70, "weight" : 130
"weightReminderWeeks" : 2 },
{
"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", "id" : "01DXF6DT006QRF1PMGK17FV505",
"name" : "Lower Body", "name" : "Lower Body",
"order" : 2, "order" : 2,
"schemaVersion" : 1, "schemaVersion" : 2,
"systemImage" : "figure.run", "systemImage" : "figure.run",
"updatedAt" : "2020-01-01T00:00:00Z" "updatedAt" : "2020-01-01T00:00:00Z"
} }
@@ -4,7 +4,6 @@
"createdAt" : "2020-01-01T00:00:00Z", "createdAt" : "2020-01-01T00:00:00Z",
"exercises" : [ "exercises" : [
{ {
"category" : 0,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT00N1B90PA7K5ABWSSP", "id" : "01DXF6DT00N1B90PA7K5ABWSSP",
"loadType" : 1, "loadType" : 1,
@@ -12,50 +11,63 @@
"order" : 0, "order" : 0,
"reps" : 10, "reps" : 10,
"sets" : 4, "sets" : 4,
"weight" : 110, "weight" : 110
"weightReminderWeeks" : 2
}, },
{ {
"category" : 0,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT00R81QRWGCCQG933FZ", "id" : "01DXF6DT00R81QRWGCCQG933FZ",
"loadType" : 1, "loadType" : 1,
"name" : "Tricep Press", "name" : "Seated Row",
"order" : 1, "order" : 1,
"reps" : 10, "reps" : 10,
"sets" : 4, "sets" : 4,
"weight" : 100, "weight" : 90
"weightReminderWeeks" : 2
}, },
{ {
"category" : 0,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT000FX4241G5MPG4WG2", "id" : "01DXF6DT000FX4241G5MPG4WG2",
"loadType" : 1, "loadType" : 1,
"name" : "Chest Press", "name" : "Shoulder Press",
"order" : 2, "order" : 2,
"reps" : 10, "reps" : 10,
"sets" : 4, "sets" : 4,
"weight" : 40, "weight" : 40
"weightReminderWeeks" : 2
}, },
{ {
"category" : 0,
"durationSeconds" : 0, "durationSeconds" : 0,
"id" : "01DXF6DT00AD5TSVJPZZVA2S41", "id" : "01DXF6DT00AD5TSVJPZZVA2S41",
"loadType" : 1, "loadType" : 1,
"name" : "Seated Row", "name" : "Chest Press",
"order" : 3, "order" : 3,
"reps" : 10, "reps" : 10,
"sets" : 4, "sets" : 4,
"weight" : 90, "weight" : 40
"weightReminderWeeks" : 2 },
{
"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", "id" : "01DXF6DT0038BDC2WC3EVX8ZJ5",
"name" : "Upper Body", "name" : "Upper Body",
"order" : 0, "order" : 0,
"schemaVersion" : 1, "schemaVersion" : 2,
"systemImage" : "figure.strengthtraining.traditional", "systemImage" : "figure.strengthtraining.traditional",
"updatedAt" : "2020-01-01T00:00:00Z" "updatedAt" : "2020-01-01T00:00:00Z"
} }
+25 -1
View File
@@ -29,8 +29,10 @@ struct ScreenshotRootView: View {
if let workout = activeWorkout { if let workout = activeWorkout {
switch ScreenshotSeed.screen(default: "workouts") { switch ScreenshotSeed.screen(default: "workouts") {
case "exercise": 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 ?? "") } NavigationStack { ExerciseView(workout: workout, logID: logID ?? "") }
case "run":
ScreenshotRunView(workout: workout)
case "settings": case "settings":
SettingsView() SettingsView()
default: 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 #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)
}
}
+3 -3
View File
@@ -19,8 +19,8 @@ enum CheckboxStatus {
switch self { switch self {
case .checked: .accentColor case .checked: .accentColor
case .unchecked: .gray case .unchecked: .gray
case .intermediate: .gray case .intermediate: .accentColor
case .cancelled: .red case .cancelled: .gray
} }
} }
@@ -29,7 +29,7 @@ enum CheckboxStatus {
case .checked: "checkmark.circle.fill" case .checked: "checkmark.circle.fill"
case .unchecked: "circle" case .unchecked: "circle"
case .intermediate: "ellipsis.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 // Local editable state
@State private var exerciseName: String @State private var exerciseName: String
@State private var originalWeight: Int
@State private var loadType: LoadType @State private var loadType: LoadType
@State private var minutes: Int @State private var minutes: Int
@State private var seconds: Int @State private var seconds: Int
@@ -31,9 +30,11 @@ struct ExerciseAddEditView: View {
@State private var weightOnes: Int @State private var weightOnes: Int
@State private var reps: Int @State private var reps: Int
@State private var sets: Int @State private var sets: Int
@State private var weightReminderWeeks: Int // Machine comfort settings. `machineEnabled` mirrors the optionality of the
@State private var weightLastUpdated: Date? // stored `machineSettings` ON persists a (possibly empty) array, OFF persists
@State private var category: ExerciseCategory // 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 @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
init(exercise: Exercise, split: Split) { init(exercise: Exercise, split: Split) {
@@ -42,7 +43,6 @@ struct ExerciseAddEditView: View {
let w = exercise.weight let w = exercise.weight
_exerciseName = State(initialValue: exercise.name) _exerciseName = State(initialValue: exercise.name)
_originalWeight = State(initialValue: w)
_loadType = State(initialValue: exercise.loadTypeEnum) _loadType = State(initialValue: exercise.loadTypeEnum)
_minutes = State(initialValue: exercise.durationMinutes) _minutes = State(initialValue: exercise.durationMinutes)
_seconds = State(initialValue: exercise.durationSeconds) _seconds = State(initialValue: exercise.durationSeconds)
@@ -50,9 +50,8 @@ struct ExerciseAddEditView: View {
_weightOnes = State(initialValue: w % 10) _weightOnes = State(initialValue: w % 10)
_reps = State(initialValue: exercise.reps) _reps = State(initialValue: exercise.reps)
_sets = State(initialValue: exercise.sets) _sets = State(initialValue: exercise.sets)
_weightReminderWeeks = State(initialValue: exercise.weightReminderTimeIntervalWeeks) _machineEnabled = State(initialValue: exercise.machineSettings != nil)
_weightLastUpdated = State(initialValue: exercise.weightLastUpdated) _machineSettings = State(initialValue: exercise.machineSettings ?? [])
_category = State(initialValue: exercise.categoryEnum)
} }
var body: some View { 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( Section(
header: Text("Load Type"), 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.") 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")) { Section(
HStack { header: Text("Machine"),
Text("Remind every \(weightReminderWeeks) weeks") 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.")
Spacer() ) {
Stepper("", value: $weightReminderWeeks, in: 0...366) Toggle("Machine-based", isOn: $machineEnabled.animation())
}
if let lastUpdated = weightLastUpdated { if machineEnabled {
Text("Last weight change \(Date().humanTimeInterval(to: lastUpdated)) ago") MachineSettingsEditor(settings: $machineSettings)
} }
} }
} }
.sheet(isPresented: $showingExercisePicker) { .sheet(isPresented: $showingExercisePicker) {
ExercisePickerView { exerciseNames in ExercisePickerView { exerciseNames in
@@ -206,7 +193,6 @@ struct ExerciseAddEditView: View {
private func saveExercise() { private func saveExercise() {
let newWeight = weightTens + weightOnes let newWeight = weightTens + weightOnes
let updatedWeightDate: Date? = newWeight != originalWeight ? Date() : weightLastUpdated
let durationSecs = minutes * 60 + seconds let durationSecs = minutes * 60 + seconds
var doc = SplitDocument(from: split) var doc = SplitDocument(from: split)
@@ -217,9 +203,8 @@ struct ExerciseAddEditView: View {
doc.exercises[idx].weight = newWeight doc.exercises[idx].weight = newWeight
doc.exercises[idx].loadType = loadType.rawValue doc.exercises[idx].loadType = loadType.rawValue
doc.exercises[idx].durationSeconds = durationSecs doc.exercises[idx].durationSeconds = durationSecs
doc.exercises[idx].weightLastUpdated = updatedWeightDate // ON a (possibly empty) array; OFF nil, discarding any prior settings.
doc.exercises[idx].weightReminderWeeks = weightReminderWeeks doc.exercises[idx].machineSettings = machineEnabled ? machineSettings : nil
doc.exercises[idx].category = category.rawValue
} }
doc.updatedAt = Date() doc.updatedAt = Date()
Task { await sync.save(split: doc) } Task { await sync.save(split: doc) }
@@ -226,14 +226,7 @@ struct ExerciseListView: View {
guard let split else { return } guard let split else { return }
let startDate = Date() let startDate = Date()
let logs = split.exercisesArray.enumerated().map { i, ex in let logs = split.exercisesArray.enumerated().map { i, ex in
WorkoutLogDocument( WorkoutLogDocument(planFrom: ExerciseDocument(from: ex), order: i, date: startDate)
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
)
} }
let doc = WorkoutDocument( let doc = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchemaVersion, schemaVersion: WorkoutDocument.currentSchemaVersion,
@@ -266,7 +259,7 @@ struct ExerciseListView: View {
id: ULID.make(), name: exName, order: base + i, id: ULID.make(), name: exName, order: base + i,
sets: 3, reps: 10, weight: 40, sets: 3, reps: 10, weight: 40,
loadType: LoadType.weight.rawValue, loadType: LoadType.weight.rawValue,
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2 durationSeconds: 0
) )
} }
doc.exercises.append(contentsOf: newDocs) doc.exercises.append(contentsOf: newDocs)
+12 -29
View File
@@ -63,20 +63,18 @@ struct SplitDetailView: View {
.font(.caption) .font(.caption)
} }
// One section per category (warm-up first). A split with no warm-ups keeps // Headerless what the exercise list is needs no label; the section
// the single plain "Exercises" section it always had. // itself keeps the visual separation.
if split.exercisesArray.isEmpty { if split.exercisesArray.isEmpty {
Section(header: Text("Exercises")) { Section {
Text("No exercises added yet.") Text("No exercises added yet.")
Button(action: { showingExerciseAddSheet.toggle() }) { Button(action: { showingExerciseAddSheet.toggle() }) {
ListItem(title: "Add Exercise") ListItem(title: "Add Exercise")
} }
} }
} else { } else {
let grouped = groupedExercises(for: split) Section {
ForEach(grouped, id: \.category) { group in ForEach(split.exercisesArray) { item in
Section(header: Text(grouped.count == 1 ? "Exercises" : group.category.displayName)) {
ForEach(group.exercises) { item in
ListItem( ListItem(
title: item.name, title: item.name,
subtitle: item.planSummary(weightUnit: weightUnit) subtitle: item.planSummary(weightUnit: weightUnit)
@@ -97,10 +95,9 @@ struct SplitDetailView: View {
} }
} }
.onMove { source, destination in .onMove { source, destination in
moveExercises(in: group.category, from: source, to: destination) moveExercises(from: source, to: destination)
} }
if group.category == grouped.last?.category {
Button { Button {
showingExerciseAddSheet = true showingExerciseAddSheet = true
} label: { } label: {
@@ -109,8 +106,6 @@ struct SplitDetailView: View {
} }
} }
} }
}
}
.navigationTitle(split.name) .navigationTitle(split.name)
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
@@ -155,26 +150,14 @@ struct SplitDetailView: View {
} }
} }
/// Exercises bucketed by category in display order, dropping empty buckets. /// Reorder and renumber. Resolves the current split at call time so it
private func groupedExercises(for split: Split) -> [(category: ExerciseCategory, exercises: [Exercise])] { /// follows a clone-on-edit.
let all = split.exercisesArray private func moveExercises(from source: IndexSet, to destination: Int) {
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) {
guard let split else { return } guard let split else { return }
var groups = groupedExercises(for: split) var ordered = split.exercisesArray
guard let gi = groups.firstIndex(where: { $0.category == category }) else { return } ordered.move(fromOffsets: source, toOffset: destination)
groups[gi].exercises.move(fromOffsets: source, toOffset: destination)
var doc = SplitDocument(from: split) var doc = SplitDocument(from: split)
let ordered = groups.flatMap(\.exercises)
doc.exercises = ordered.enumerated().map { i, ex in doc.exercises = ordered.enumerated().map { i, ex in
var ed = ExerciseDocument(from: ex) var ed = ExerciseDocument(from: ex)
ed.order = i ed.order = i
@@ -197,7 +180,7 @@ struct SplitDetailView: View {
id: ULID.make(), name: exName, order: base + i, id: ULID.make(), name: exName, order: base + i,
sets: 3, reps: 10, weight: 40, sets: 3, reps: 10, weight: 40,
loadType: LoadType.weight.rawValue, loadType: LoadType.weight.rawValue,
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2 durationSeconds: 0
) )
} }
doc.exercises.append(contentsOf: newDocs) doc.exercises.append(contentsOf: newDocs)
+51 -13
View File
@@ -10,12 +10,19 @@
import SwiftUI import SwiftUI
import SwiftData 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 { struct SplitListView: View {
@Environment(SyncEngine.self) private var sync @Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@Query(sort: \Split.order) private var splits: [Split] @Query(sort: \Split.order) private var splits: [Split]
@State private var showingAddSheet = false
@State private var splitToDelete: Split?
var body: some View { var body: some View {
ScrollView { ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
@@ -25,6 +32,13 @@ struct SplitListView: View {
} label: { } label: {
SplitItem(split: split) SplitItem(split: split)
} }
.contextMenu {
Button(role: .destructive) {
splitToDelete = split
} label: {
Label("Delete", systemImage: "trash")
}
}
} }
} }
.padding() .padding()
@@ -32,20 +46,44 @@ struct SplitListView: View {
.overlay { .overlay {
if splits.isEmpty { if splits.isEmpty {
ContentUnavailableView( ContentUnavailableView(
label: { "No Splits Yet",
Label("No Splits Yet", systemImage: "dumbbell.fill") systemImage: "dumbbell.fill",
}, description: Text("Create a split to organize your workout routine.")
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)
}
) )
} }
} }
.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.")
}
} }
} }
-31
View File
@@ -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. /// so completing the exercise from inside the flow doesn't swap the page mid-dismiss.
@State private var startsCompleted: Bool @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) { init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
self._doc = doc self._doc = doc
self.logID = logID self.logID = logID
@@ -109,6 +114,7 @@ struct ExerciseProgressView: View {
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
_startsResumed = State(initialValue: !notStarted) _startsResumed = State(initialValue: !notStarted)
_startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue) _startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue)
_startsSkipped = State(initialValue: log?.status == WorkoutStatus.skipped.rawValue)
let base = 1 let base = 1
// Resume on the first unfinished set's work page (clamped to the last set). // Resume on the first unfinished set's work page (clamped to the last set).
@@ -189,7 +195,19 @@ struct ExerciseProgressView: View {
var body: some View { var body: some View {
if startsCompleted { if startsCompleted {
CompletedPhaseView() // 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 ?? "") .navigationTitle(log?.exerciseName ?? "")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} else { } else {
@@ -462,7 +480,7 @@ struct ExerciseProgressView: View {
private func beginExercise() { private func beginExercise() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
guard doc.logs[i].status == WorkoutStatus.notStarted.rawValue 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() recomputeWorkoutStatus()
doc.updatedAt = Date() doc.updatedAt = Date()
onChange() onChange()
@@ -476,8 +494,7 @@ struct ExerciseProgressView: View {
if let i = doc.logs.firstIndex(where: { $0.id == logID }) { if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
doc.logs[i].sets = newCount doc.logs[i].sets = newCount
doc.logs[i].currentStateIndex = newCount - 1 // every prior set is now complete doc.logs[i].currentStateIndex = newCount - 1 // every prior set is now complete
doc.logs[i].status = WorkoutStatus.inProgress.rawValue doc.logs[i].transition(to: .inProgress)
doc.logs[i].completed = false
recomputeWorkoutStatus() recomputeWorkoutStatus()
doc.updatedAt = Date() doc.updatedAt = Date()
onChange() onChange()
@@ -504,8 +521,7 @@ struct ExerciseProgressView: View {
guard reached > doc.logs[i].currentStateIndex else { return } guard reached > doc.logs[i].currentStateIndex else { return }
doc.logs[i].currentStateIndex = reached doc.logs[i].currentStateIndex = reached
doc.logs[i].status = WorkoutStatus.inProgress.rawValue doc.logs[i].transition(to: .inProgress)
doc.logs[i].completed = false
recomputeWorkoutStatus() recomputeWorkoutStatus()
doc.updatedAt = Date() doc.updatedAt = Date()
@@ -518,11 +534,9 @@ struct ExerciseProgressView: View {
let log = doc.logs[i] let log = doc.logs[i]
// Skip the write if it's already pristine (e.g. landing on Ready before any set). // Skip the write if it's already pristine (e.g. landing on Ready before any set).
guard log.currentStateIndex != 0 guard log.currentStateIndex != 0
|| log.status != WorkoutStatus.notStarted.rawValue || log.status != WorkoutStatus.notStarted.rawValue else { return }
|| log.completed else { return }
doc.logs[i].currentStateIndex = 0 doc.logs[i].currentStateIndex = 0
doc.logs[i].status = WorkoutStatus.notStarted.rawValue doc.logs[i].transition(to: .notStarted)
doc.logs[i].completed = false
recomputeWorkoutStatus() recomputeWorkoutStatus()
doc.updatedAt = Date() doc.updatedAt = Date()
onChange() onChange()
@@ -531,8 +545,7 @@ struct ExerciseProgressView: View {
private func completeExercise() { private func completeExercise() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].currentStateIndex = setCount doc.logs[i].currentStateIndex = setCount
doc.logs[i].status = WorkoutStatus.completed.rawValue doc.logs[i].transition(to: .completed)
doc.logs[i].completed = true
recomputeWorkoutStatus() recomputeWorkoutStatus()
doc.updatedAt = Date() doc.updatedAt = Date()
@@ -696,15 +709,71 @@ private extension View {
// MARK: - Completed Phase // 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 { 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 { var body: some View {
VStack(spacing: 14) { VStack(spacing: 14) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.font(.system(size: 96)) .font(.system(size: 96, weight: .bold))
.foregroundStyle(.green) .foregroundStyle(Color.accentColor)
Text("Completed") Text("Completed")
.font(.system(.title, design: .rounded, weight: .heavy)) .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) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
+8 -104
View File
@@ -12,35 +12,25 @@ import SwiftData
import Charts import Charts
struct ExerciseView: View { struct ExerciseView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services @Environment(AppServices.self) private var services
@Environment(\.dismiss) private var dismiss
let workout: Workout let workout: Workout
let logID: String let logID: String
/// Working copy of the parent workout. Editing a log = editing this doc and /// Working copy of the parent workout. Driving the UI from local state (not the
/// re-saving the whole aggregate. Driving the UI from local state (not the /// cache entity) lets `seedDoc` show a just-added log before the filecache
/// cache entity) keeps rapid set taps from racing the filecache update. /// round-trip catches up.
@State private var doc: WorkoutDocument @State private var doc: WorkoutDocument
@State private var progress: Int = 0
@State private var showingPlanEdit = false @State private var showingPlanEdit = false
@State private var showingNotesEdit = 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 /// `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 /// working copy right after adding an exercise) so the screen doesn't wait on
/// the filecache round-trip to find the just-created log. /// the filecache round-trip to find the just-created log.
init(workout: Workout, logID: String, seedDoc: WorkoutDocument? = nil) { init(workout: Workout, logID: String, seedDoc: WorkoutDocument? = nil) {
self.workout = workout self.workout = workout
self.logID = logID self.logID = logID
let initialDoc = seedDoc ?? WorkoutDocument(from: workout) _doc = State(initialValue: 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)
} }
/// The log being edited within the working doc. /// The log being edited within the working doc.
@@ -66,7 +56,6 @@ struct ExerciseView: View {
} }
.onAppear { .onAppear {
refreshDocIfNeeded() refreshDocIfNeeded()
progress = log?.currentStateIndex ?? 0
// Take over this run: the watch parks and locks it while we're editing here. // Take over this run: the watch parks and locks it while we're editing here.
services.watchBridge.setEditingWorkout(workout.id) 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. /// update without leaving and re-entering the screen.
private func absorbExternalUpdate() { private func absorbExternalUpdate() {
let fresh = WorkoutDocument(from: workout) doc = WorkoutDocument(from: workout)
doc = fresh
progress = fresh.logs.first(where: { $0.id == logID })?.currentStateIndex ?? progress
} }
@ViewBuilder @ViewBuilder
private func content(for log: WorkoutLogDocument) -> some View { private func content(for log: WorkoutLogDocument) -> some View {
Form { 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) // MARK: - Plan Section (Read-only with Edit button)
Section { Section {
PlanTilesView(log: log) 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 /// 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. /// fresh copy from the cache entity once it catches up.
private func refreshDocIfNeeded() { private func refreshDocIfNeeded() {
@@ -222,21 +138,9 @@ struct ExerciseView: View {
} }
/// Re-read the workout from the cache to absorb edits made by child sheets /// 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() { private func refreshDocFromCache() {
let fresh = WorkoutDocument(from: workout) doc = 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
}
} }
} }
@@ -160,10 +160,12 @@ struct PlanEditView: View {
let exerciseName = doc.logs[i].exerciseName let exerciseName = doc.logs[i].exerciseName
let workoutDoc = doc 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? var splitDoc: SplitDocument?
if let splitID = doc.splitID, 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) var sDoc = SplitDocument(from: split)
if let ei = sDoc.exercises.firstIndex(where: { $0.name == exerciseName }) { if let ei = sDoc.exercises.firstIndex(where: { $0.name == exerciseName }) {
sDoc.exercises[ei].sets = sets sDoc.exercises[ei].sets = sets
@@ -20,8 +20,12 @@ struct WeightProgressionChartView: View {
init(exerciseName: String) { init(exerciseName: String) {
self.exerciseName = exerciseName self.exerciseName = exerciseName
let name = 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( _logs = Query(
filter: #Predicate<WorkoutLog> { $0.exerciseName == name && $0.completed }, filter: #Predicate<WorkoutLog> { $0.exerciseName == name && $0.statusRaw == completedRaw },
sort: \WorkoutLog.date, sort: \WorkoutLog.date,
order: .forward order: .forward
) )
@@ -32,6 +32,7 @@ struct WorkoutLogListView: View {
@State private var addedLog: LogRoute? @State private var addedLog: LogRoute?
@State private var logToEdit: LogRoute? @State private var logToEdit: LogRoute?
@State private var summaryRoute: LogRoute? @State private var summaryRoute: LogRoute?
@State private var settingsRoute: LogRoute?
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
/// Drives a programmatic push keyed by a log id, used for the freshly-added /// 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 } 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? { private var split: Split? {
guard let splitID = doc.splitID else { return nil } 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 { var body: some View {
@@ -73,24 +76,27 @@ struct WorkoutLogListView: View {
} }
} else { } else {
Form { Form {
if doc.status == WorkoutStatus.completed.rawValue, doc.metrics != nil { Section {
Section("Summary") { LabeledContent("Started", value: doc.start.formattedRelativeDateTime())
WorkoutMetricsView(workout: workout) 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 ForEach(sortedLogs) { log in
NavigationLink { NavigationLink {
progressView(logID: log.id) progressView(logID: log.id)
} label: { } label: {
CheckboxListItem( WorkoutLogRow(
status: workoutStatus(log).checkboxStatus, status: workoutStatus(log).checkboxStatus,
title: log.exerciseName, log: log,
subtitle: subtitleForLog(log) weightUnit: weightUnit,
) { onCheckboxTap: { cycleStatus(for: log) },
cycleStatus(for: log) onSettingsTap: { settingsRoute = LogRoute(id: log.id) }
} )
} }
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
Button { Button {
@@ -116,9 +122,20 @@ struct WorkoutLogListView: View {
.tint(.blue) .tint(.blue)
} }
} }
.onMove(perform: moveLog) .onMove { source, destination in
moveLog(from: source, to: destination)
}
} }
// 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 { Section {
Button { Button {
showingEndOptions = true showingEndOptions = true
@@ -130,6 +147,7 @@ struct WorkoutLogListView: View {
} }
} }
} }
}
.navigationDestination(item: $addedLog) { route in .navigationDestination(item: $addedLog) { route in
// A freshly-added exercise drops straight into its progress flow. The new // A freshly-added exercise drops straight into its progress flow. The new
// log already lives in our working doc, so the binding has it before the // log already lives in our working doc, so the binding has it before the
@@ -199,6 +217,16 @@ struct WorkoutLogListView: View {
dismiss() 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 /// The paged run flow, fully wired into the live channel: it broadcasts this device's
@@ -220,16 +248,8 @@ struct WorkoutLogListView: View {
// MARK: - Derived // MARK: - Derived
private var label: String { private var statusEnum: WorkoutStatus {
if doc.status == WorkoutStatus.completed.rawValue, let end = doc.end { WorkoutStatus(rawValue: doc.status) ?? .notStarted
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 func workoutStatus(_ log: WorkoutLogDocument) -> WorkoutStatus { private func workoutStatus(_ log: WorkoutLogDocument) -> WorkoutStatus {
@@ -253,15 +273,13 @@ struct WorkoutLogListView: View {
case .completed: next = .notStarted case .completed: next = .notStarted
case .skipped: next = .notStarted case .skipped: next = .notStarted
} }
doc.logs[i].status = next.rawValue doc.logs[i].transition(to: next)
doc.logs[i].completed = (next == .completed)
save() save()
} }
private func completeLog(_ log: WorkoutLogDocument) { private func completeLog(_ log: WorkoutLogDocument) {
guard let i = doc.logs.firstIndex(where: { $0.id == log.id }) else { return } guard let i = doc.logs.firstIndex(where: { $0.id == log.id }) else { return }
doc.logs[i].status = WorkoutStatus.completed.rawValue doc.logs[i].transition(to: .completed)
doc.logs[i].completed = true
save() save()
} }
@@ -292,21 +310,7 @@ struct WorkoutLogListView: View {
} }
doc.end = nil doc.end = nil
let newLog = WorkoutLogDocument( let newLog = WorkoutLogDocument(planFrom: ExerciseDocument(from: exercise), order: doc.logs.count, date: now)
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
)
doc.logs.append(newLog) doc.logs.append(newLog)
save() save()
@@ -314,6 +318,44 @@ struct WorkoutLogListView: View {
addedLog = LogRoute(id: newLog.id) 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. /// Recompute the workout's status/end from its logs, then persist.
private func save() { private func save() {
doc.recomputeStatusFromLogs() doc.recomputeStatusFromLogs()
@@ -341,8 +383,26 @@ struct WorkoutLogListView: View {
Task { await sync.delete(workout: target) } 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 mins = log.durationSeconds / 60
let secs = log.durationSeconds % 60 let secs = log.durationSeconds % 60
if mins > 0 && secs > 0 { if mins > 0 && secs > 0 {
@@ -353,9 +413,61 @@ struct WorkoutLogListView: View {
return "\(log.sets) × \(secs) sec" return "\(log.sets) × \(secs) sec"
} }
} else { } 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 // 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) { private func start(with split: Split) {
let startDate = Date() let startDate = Date()
let logs = split.exercisesArray.enumerated().map { index, exercise in let logs = split.exercisesArray.enumerated().map { index, exercise in
WorkoutLogDocument( WorkoutLogDocument(planFrom: ExerciseDocument(from: exercise), order: index, date: startDate)
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
)
} }
// A freshly started workout has no `end` only completion stamps it. // A freshly started workout has no `end` only completion stamps it.
+111 -13
View File
@@ -3,9 +3,12 @@ import Testing
import IndieSync import IndieSync
@testable import Workouts @testable import Workouts
/// Round-trips `SplitDocument` through `DocumentCoder` the exact JSON codec /// Round-trips `SplitDocument` (and the embedded exercise / log shapes) through
/// (ISO-8601 dates, sorted keys, pretty-printed) `DocumentFileStore` uses to /// `DocumentCoder` the exact JSON codec (ISO-8601 dates, sorted keys,
/// write and read every document in this app's iCloud container. /// pretty-printed) `DocumentFileStore` uses to write and read every document in
/// this app's iCloud container. Also pins the schema-v2 migration: an old v1
/// split file (with the removed weight-reminder / category keys) must still decode
/// and round-trip at the current version.
struct SplitDocumentCodableTests { struct SplitDocumentCodableTests {
/// A whole-second epoch so ISO-8601 round-trips (no fractional-second loss). /// A whole-second epoch so ISO-8601 round-trips (no fractional-second loss).
private static let stableTimestamp = Date(timeIntervalSince1970: 1_700_000_000) private static let stableTimestamp = Date(timeIntervalSince1970: 1_700_000_000)
@@ -13,16 +16,17 @@ struct SplitDocumentCodableTests {
@Test func encodeDecodeRoundTrip() throws { @Test func encodeDecodeRoundTrip() throws {
let exercise = ExerciseDocument( let exercise = ExerciseDocument(
id: ULID.make(), id: ULID.make(),
name: "Bench Press", name: "Leg Press",
order: 0, order: 0,
sets: 4, sets: 4,
reps: 10, reps: 10,
weight: 135, weight: 135,
loadType: 0, loadType: 0,
durationSeconds: 0, durationSeconds: 0,
weightLastUpdated: Self.stableTimestamp, machineSettings: [
weightReminderWeeks: 4, MachineSetting(name: "Seat Height", value: "4"),
category: ExerciseCategory.warmup.rawValue MachineSetting(name: "Back Rest Incline", value: "45°")
]
) )
let original = SplitDocument( let original = SplitDocument(
schemaVersion: SplitDocument.currentSchemaVersion, schemaVersion: SplitDocument.currentSchemaVersion,
@@ -41,12 +45,65 @@ struct SplitDocumentCodableTests {
let decoded = try DocumentCoder.decode(SplitDocument.self, from: data) let decoded = try DocumentCoder.decode(SplitDocument.self, from: data)
#expect(decoded == original) #expect(decoded == original)
#expect(decoded.schemaVersion == 2)
#expect(decoded.relativePath == "Splits/\(original.id).json") #expect(decoded.relativePath == "Splits/\(original.id).json")
#expect(decoded.exercises.first?.machineSettings?.count == 2)
} }
/// A pre-category exercise (no `category` key) must still decode the field is /// An `ExerciseDocument` with no machine settings encodes without a
/// optional and deliberately not schema-bumped, defaulting to the main circuit. /// `machineSettings` key (nil optionals are omitted) and decodes back to nil
@Test func decodesLegacyExerciseWithoutCategory() throws { /// the "not a machine exercise" state.
@Test func nonMachineExerciseOmitsMachineSettings() throws {
let exercise = ExerciseDocument(
id: ULID.make(), name: "Plank", order: 0, sets: 3, reps: 0,
weight: 0, loadType: 2, durationSeconds: 45
)
let data = try DocumentCoder.encode(exercise)
let json = String(decoding: data, as: UTF8.self)
#expect(!json.contains("machineSettings"))
let decoded = try DocumentCoder.decode(ExerciseDocument.self, from: data)
#expect(decoded.machineSettings == nil)
}
/// `machineSettings` round-trips on `ExerciseDocument`, including the empty case
/// ("machine-based, nothing recorded yet") which must stay distinct from nil.
@Test func machineSettingsRoundTripOnExercise() throws {
for settings: [MachineSetting] in [
[MachineSetting(name: "Pin", value: "3rd hole")],
[] // empty nil: a machine exercise with nothing recorded
] {
let exercise = ExerciseDocument(
id: ULID.make(), name: "Chest Press", order: 0, sets: 4, reps: 10,
weight: 40, loadType: 1, durationSeconds: 0, machineSettings: settings
)
let data = try DocumentCoder.encode(exercise)
let decoded = try DocumentCoder.decode(ExerciseDocument.self, from: data)
#expect(decoded.machineSettings == settings)
}
}
/// `machineSettings` round-trips on `WorkoutLogDocument` the plan-time snapshot.
@Test func machineSettingsRoundTripOnWorkoutLog() throws {
let log = WorkoutLogDocument(
id: ULID.make(), exerciseName: "Leg Press", order: 0, sets: 4, reps: 10,
weight: 140, loadType: 1, durationSeconds: 0, currentStateIndex: 0,
status: WorkoutStatus.notStarted.rawValue, notes: nil,
date: Self.stableTimestamp,
machineSettings: [MachineSetting(name: "Seat Height", value: "4")]
)
let data = try DocumentCoder.encode(log)
let decoded = try DocumentCoder.decode(WorkoutLogDocument.self, from: data)
#expect(decoded == log)
#expect(decoded.machineSettings == [MachineSetting(name: "Seat Height", value: "4")])
}
/// A v1 split file carrying the since-removed keys (`weightLastUpdated`,
/// `weightReminderWeeks`, `category`) must still decode Codable ignores the
/// extra keys, and every remaining field is present in old files. Re-stamping
/// `schemaVersion` to the current value (what the app does on any rewrite via the
/// cachedocument mapper) round-trips cleanly at v2.
@Test func decodesLegacyV1SplitAndRoundTripsAtV2() throws {
let json = """ let json = """
{ {
"schemaVersion": 1, "schemaVersion": 1,
@@ -66,11 +123,52 @@ struct SplitDocumentCodableTests {
"weight": 135, "weight": 135,
"loadType": 1, "loadType": 1,
"durationSeconds": 0, "durationSeconds": 0,
"weightReminderWeeks": 4 "weightLastUpdated": "2023-11-14T22:13:20Z",
"weightReminderWeeks": 4,
"category": 1
}] }]
} }
""" """
let decoded = try DocumentCoder.decode(SplitDocument.self, from: Data(json.utf8)) var decoded = try DocumentCoder.decode(SplitDocument.self, from: Data(json.utf8))
#expect(decoded.exercises.first?.category == nil) // Removed keys are ignored; the surviving fields decode and machineSettings
// defaults to nil (the file predates it).
#expect(decoded.schemaVersion == 1)
#expect(decoded.exercises.count == 1)
#expect(decoded.exercises.first?.name == "Bench Press")
#expect(decoded.exercises.first?.weight == 135)
#expect(decoded.exercises.first?.machineSettings == nil)
// The app re-stamps to the current version on rewrite; that must round-trip.
decoded.schemaVersion = SplitDocument.currentSchemaVersion
let reData = try DocumentCoder.encode(decoded)
let reDecoded = try DocumentCoder.decode(SplitDocument.self, from: reData)
#expect(reDecoded == decoded)
#expect(reDecoded.schemaVersion == 2)
}
/// A workout-log file written before `completed` was removed still carries the
/// key. It must decode fine (Codable ignores it) completion is now derived
/// from `status`.
@Test func decodesLegacyWorkoutLogWithCompletedKey() throws {
let json = """
{
"id": "01HZZZZZZZZZZZZZZZZZZZZZZW",
"exerciseName": "Bench Press",
"order": 0,
"sets": 4,
"reps": 10,
"weight": 135,
"loadType": 1,
"durationSeconds": 0,
"currentStateIndex": 4,
"completed": true,
"status": "completed",
"date": "2023-11-14T22:13:20Z"
}
"""
let decoded = try DocumentCoder.decode(WorkoutLogDocument.self, from: Data(json.utf8))
#expect(decoded.status == WorkoutStatus.completed.rawValue)
#expect(decoded.exerciseName == "Bench Press")
#expect(decoded.machineSettings == nil)
} }
} }
@@ -0,0 +1,62 @@
import Foundation
import Testing
import IndieSync
@testable import Workouts
/// Pins the plan-time snapshot (`WorkoutLogDocument(planFrom:order:date:)`) the
/// single helper every "start workout" / "add exercise" path funnels through. The
/// machine comfort settings must ride along with the other plan fields, and the
/// nil-vs-empty distinction (not-a-machine vs machine-with-nothing-recorded) must
/// be preserved exactly.
struct WorkoutLogSnapshotTests {
private static let ts = Date(timeIntervalSince1970: 1_700_000_000)
@Test func snapshotCopiesMachineSettingsAndPlanFields() {
let exercise = ExerciseDocument(
id: ULID.make(), name: "Leg Press", order: 2, sets: 4, reps: 10,
weight: 140, loadType: 1, durationSeconds: 0,
machineSettings: [
MachineSetting(name: "Seat Height", value: "4"),
MachineSetting(name: "Back Rest Incline", value: "45°")
]
)
let log = WorkoutLogDocument(planFrom: exercise, order: 5, date: Self.ts)
#expect(log.machineSettings == exercise.machineSettings)
#expect(log.exerciseName == "Leg Press")
#expect(log.order == 5) // the passed order, not the exercise's
#expect(log.sets == 4)
#expect(log.reps == 10)
#expect(log.weight == 140)
#expect(log.loadType == 1)
#expect(log.durationSeconds == 0)
#expect(log.date == Self.ts)
#expect(log.currentStateIndex == 0)
#expect(log.status == WorkoutStatus.notStarted.rawValue)
}
@Test func snapshotPreservesNilForNonMachineExercise() {
let exercise = ExerciseDocument(
id: ULID.make(), name: "Plank", order: 0, sets: 3, reps: 0,
weight: 0, loadType: 2, durationSeconds: 45
)
let log = WorkoutLogDocument(planFrom: exercise, order: 0, date: Self.ts)
#expect(log.machineSettings == nil)
}
@Test func snapshotPreservesEmptyMachineSettings() {
// Empty nil: a machine exercise with nothing recorded yet must stay empty,
// not collapse to "not a machine exercise".
let exercise = ExerciseDocument(
id: ULID.make(), name: "Chest Press", order: 0, sets: 4, reps: 10,
weight: 40, loadType: 1, durationSeconds: 0, machineSettings: []
)
let log = WorkoutLogDocument(planFrom: exercise, order: 0, date: Self.ts)
#expect(log.machineSettings == [])
}
}