diff --git a/SCHEMA.md b/SCHEMA.md new file mode 100644 index 0000000..72e8ed6 --- /dev/null +++ b/SCHEMA.md @@ -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/.json` | +| Workout (embeds its logs) | `Workouts/YYYY/MM/.json` (local-calendar month of `start`) | +| Tombstone (soft-delete stub) | `Stubs/` | + +`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. diff --git a/Scripts/generate_starter_splits.swift b/Scripts/generate_starter_splits.swift index d767069..c3fae5b 100644 --- a/Scripts/generate_starter_splits.swift +++ b/Scripts/generate_starter_splits.swift @@ -38,6 +38,11 @@ func deterministicULID(label: String) -> String { // MARK: - Document shapes (field names must mirror Shared/Model/Documents.swift) +struct MachineSetting: Codable { + var name: String + var value: String +} + struct ExerciseDocument: Codable { var id: String var name: String @@ -47,9 +52,7 @@ struct ExerciseDocument: Codable { var weight: Int var loadType: Int var durationSeconds: Int - var weightLastUpdated: Date? - var weightReminderWeeks: Int - var category: Int? + var machineSettings: [MachineSetting]? = nil } struct SplitDocument: Codable { @@ -67,30 +70,34 @@ struct SplitDocument: Codable { // 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] } let seeds: [Sp] = [ Sp(name: "Upper Body", color: "blue", icon: "figure.strengthtraining.traditional", activity: 0, ex: [ 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: "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: [ - Ex(name: "Abdominal", weight: 0), - Ex(name: "Rotary", weight: 0), + Ex(name: "Abdominal", weight: 40), + Ex(name: "Rotary", weight: 40), ]), Sp(name: "Lower Body", color: "green", icon: "figure.run", activity: 0, ex: [ - Ex(name: "Abductor", weight: 140), - Ex(name: "Adductor", weight: 140), - Ex(name: "Leg Press", weight: 160), - Ex(name: "Leg Curl", weight: 70), + Ex(name: "Leg Press", weight: 140), + Ex(name: "Leg Curl", weight: 40), + Ex(name: "Leg Extension", weight: 80), + 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: [ - Ex(name: "Cat-Cow", sets: 1, reps: 10, load: 0, cat: 1), - Ex(name: "Dead Bug", sets: 1, reps: 8, load: 0, cat: 1), - Ex(name: "Bird Dog", sets: 1, reps: 8, load: 0, cat: 1), + Ex(name: "Cat-Cow", sets: 1, reps: 10, load: 0), + Ex(name: "Dead Bug", sets: 1, reps: 8, load: 0), + Ex(name: "Bird Dog", sets: 1, reps: 8, load: 0), 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: "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() { let slug = sp.name.lowercased().replacingOccurrences(of: " ", with: "-") let doc = SplitDocument( - schemaVersion: 1, + schemaVersion: 2, id: deterministicULID(label: "starter.\(slug)"), name: sp.name, color: sp.color, systemImage: sp.icon, order: order, createdAt: fixedDate, updatedAt: fixedDate, @@ -121,8 +128,7 @@ for (order, sp) in seeds.enumerated() { ExerciseDocument( id: deterministicULID(label: "starter.\(slug).ex\(idx)"), name: e.name, order: idx, sets: e.sets, reps: e.reps, weight: e.weight, - loadType: e.load, durationSeconds: e.dur, weightLastUpdated: nil, - weightReminderWeeks: 2, category: e.cat) + loadType: e.load, durationSeconds: e.dur) }, activityType: sp.activity) let data = try encoder.encode(doc) diff --git a/Shared/Model/Documents.swift b/Shared/Model/Documents.swift index f15370f..9a72895 100644 --- a/Shared/Model/Documents.swift +++ b/Shared/Model/Documents.swift @@ -30,7 +30,12 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable { /// quarantining the user's whole routine. var activityType: Int? - static let currentSchemaVersion = 1 + // Bumped 1→2 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" } } @@ -44,12 +49,19 @@ struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable { var weight: Int var loadType: Int var durationSeconds: Int // total seconds (0 when not a timed exercise) - var weightLastUpdated: Date? - var weightReminderWeeks: Int - /// Raw `ExerciseCategory`; nil → main circuit. Like `activityType`, deliberately - /// NOT schema-bumped: it only affects how the split screen groups its list, so an - /// older app dropping it on rewrite beats quarantining the routine. - var category: Int? + /// Ordered, user-defined comfort settings for machine-based exercises (seat + /// height, back rest incline, pin position…). `nil` → not a machine exercise; + /// empty → a machine exercise with nothing recorded yet. This optionality + /// doubles as the machine-based flag — there is no separate Bool. + var machineSettings: [MachineSetting]? = nil +} + +/// 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 @@ -74,7 +86,11 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable { // Bumped 1→2 when `metrics` was added: the captured HR/calorie data is // irreplaceable, so the forward-gate must quarantine these files on older apps // rather than let them strip `metrics` on rewrite. - static let currentSchemaVersion = 2 + // Bumped 2→3 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) } @@ -119,8 +135,7 @@ extension WorkoutDocument { /// start-a-new-split prompt. mutating func endKeepingProgress() { for i in logs.indices where (WorkoutStatus(rawValue: logs[i].status) ?? .notStarted) != .completed { - logs[i].status = WorkoutStatus.skipped.rawValue - logs[i].completed = false + logs[i].transition(to: .skipped) } recomputeStatusFromLogs() updatedAt = Date() @@ -137,10 +152,64 @@ struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable { var loadType: Int var durationSeconds: Int // total seconds (0 when not a timed exercise) var currentStateIndex: Int - var completed: Bool var status: String // WorkoutStatus raw value var notes: String? 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 diff --git a/Shared/Model/Entities.swift b/Shared/Model/Entities.swift index 407eb25..c20a62f 100644 --- a/Shared/Model/Entities.swift +++ b/Shared/Model/Entities.swift @@ -62,15 +62,16 @@ final class Exercise { var weight: Int = 0 var loadType: Int = LoadType.weight.rawValue var durationTotalSeconds: Int = 0 - var weightLastUpdated: Date? - var weightReminderTimeIntervalWeeks: Int = 2 - var categoryRaw: Int = ExerciseCategory.main.rawValue + // Machine comfort settings — stored directly as an optional Codable array + // (SwiftData persists the `[MachineSetting]` composite), mirroring how + // `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? init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Int, - loadType: Int, durationTotalSeconds: Int, weightLastUpdated: Date?, - weightReminderTimeIntervalWeeks: Int, categoryRaw: Int = ExerciseCategory.main.rawValue) { + loadType: Int, durationTotalSeconds: Int, machineSettings: [MachineSetting]? = nil) { self.id = id self.name = name self.order = order @@ -79,9 +80,7 @@ final class Exercise { self.weight = weight self.loadType = loadType self.durationTotalSeconds = durationTotalSeconds - self.weightLastUpdated = weightLastUpdated - self.weightReminderTimeIntervalWeeks = weightReminderTimeIntervalWeeks - self.categoryRaw = categoryRaw + self.machineSettings = machineSettings } var loadTypeEnum: LoadType { @@ -89,11 +88,6 @@ final class Exercise { 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 /// ("3 × 45 sec"), unloaded ones drop the weight ("3 × 12 reps"), weighted ones /// keep the full "3 × 10 × 40 lb". @@ -233,16 +227,22 @@ final class WorkoutLog { var loadType: Int = LoadType.weight.rawValue var durationTotalSeconds: Int = 0 var currentStateIndex: Int = 0 - var completed: Bool = false var statusRaw: String = WorkoutStatus.notStarted.rawValue var notes: String? 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? init(id: String, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int, - loadType: Int, durationTotalSeconds: Int, currentStateIndex: Int, completed: Bool, - statusRaw: String, notes: String?, date: Date) { + loadType: Int, durationTotalSeconds: Int, currentStateIndex: Int, + statusRaw: String, notes: String?, date: Date, + machineSettings: [MachineSetting]? = nil, + startedAt: Date? = nil, completedAt: Date? = nil) { self.id = id self.exerciseName = exerciseName self.order = order @@ -252,10 +252,12 @@ final class WorkoutLog { self.loadType = loadType self.durationTotalSeconds = durationTotalSeconds self.currentStateIndex = currentStateIndex - self.completed = completed self.statusRaw = statusRaw self.notes = notes self.date = date + self.machineSettings = machineSettings + self.startedAt = startedAt + self.completedAt = completedAt } var status: WorkoutStatus { diff --git a/Shared/Model/Enums.swift b/Shared/Model/Enums.swift index 5d108b5..92e12ff 100644 --- a/Shared/Model/Enums.swift +++ b/Shared/Model/Enums.swift @@ -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 /// 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. diff --git a/Shared/Model/Mappers.swift b/Shared/Model/Mappers.swift index c779029..e43a832 100644 --- a/Shared/Model/Mappers.swift +++ b/Shared/Model/Mappers.swift @@ -17,9 +17,7 @@ extension ExerciseDocument { init(from e: Exercise) { 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, - weightLastUpdated: e.weightLastUpdated, - weightReminderWeeks: e.weightReminderTimeIntervalWeeks, - category: e.categoryRaw) + machineSettings: e.machineSettings) } } @@ -38,7 +36,9 @@ extension WorkoutLogDocument { self.init(id: log.id, exerciseName: log.exerciseName, order: log.order, sets: log.sets, reps: log.reps, weight: log.weight, loadType: log.loadType, 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 { 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, - weightLastUpdated: d.weightLastUpdated, - weightReminderTimeIntervalWeeks: d.weightReminderWeeks, - categoryRaw: d.category ?? ExerciseCategory.main.rawValue) + machineSettings: d.machineSettings) } private static func apply(_ d: ExerciseDocument, to e: Exercise) { @@ -121,9 +119,7 @@ enum CacheMapper { e.weight = d.weight e.loadType = d.loadType e.durationTotalSeconds = d.durationSeconds - e.weightLastUpdated = d.weightLastUpdated - e.weightReminderTimeIntervalWeeks = d.weightReminderWeeks - e.categoryRaw = d.category ?? ExerciseCategory.main.rawValue + e.machineSettings = d.machineSettings } // MARK: Workout @@ -169,8 +165,9 @@ enum CacheMapper { private static func makeLog(_ d: WorkoutLogDocument) -> WorkoutLog { 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, - currentStateIndex: d.currentStateIndex, completed: d.completed, statusRaw: d.status, - notes: d.notes, date: d.date) + currentStateIndex: d.currentStateIndex, statusRaw: d.status, + notes: d.notes, date: d.date, machineSettings: d.machineSettings, + startedAt: d.startedAt, completedAt: d.completedAt) } private static func apply(_ d: WorkoutLogDocument, to l: WorkoutLog) { @@ -182,9 +179,11 @@ enum CacheMapper { l.loadType = d.loadType l.durationTotalSeconds = d.durationSeconds l.currentStateIndex = d.currentStateIndex - l.completed = d.completed l.statusRaw = d.status l.notes = d.notes l.date = d.date + l.machineSettings = d.machineSettings + l.startedAt = d.startedAt + l.completedAt = d.completedAt } } diff --git a/Shared/Persistence/WorkoutsModelContainer.swift b/Shared/Persistence/WorkoutsModelContainer.swift index 59dd611..80d713b 100644 --- a/Shared/Persistence/WorkoutsModelContainer.swift +++ b/Shared/Persistence/WorkoutsModelContainer.swift @@ -8,7 +8,9 @@ enum WorkoutsModelContainer { /// Bump whenever the cache schema changes — wipes and rebuilds from files. /// 2: added health-metric columns to `Workout` and `activityTypeRaw` to `Split`. /// 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 identityTokenKey = "Workouts.iCloudIdentityToken" diff --git a/Shared/Screenshots/ScreenshotSeed.swift b/Shared/Screenshots/ScreenshotSeed.swift index b7a3129..a18318f 100644 --- a/Shared/Screenshots/ScreenshotSeed.swift +++ b/Shared/Screenshots/ScreenshotSeed.swift @@ -30,18 +30,20 @@ enum ScreenshotSeed { func daysAgo(_ n: Int) -> Date { cal.date(byAdding: .day, value: -n, to: today) ?? today } // ---- 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)])] = [ - ("Upper Body", "purple", "dumbbell.fill", [ - ("Bench Press", 4, 10, 135), ("Overhead Press", 4, 10, 75), - ("Lat Pulldown", 4, 12, 120), ("Bicep Curl", 3, 12, 30), + ("Upper Body", "blue", "figure.strengthtraining.traditional", [ + ("Lat Pull Down", 4, 10, 110), ("Seated Row", 4, 10, 90), + ("Shoulder Press", 4, 10, 40), ("Arm Curl", 3, 12, 40), ]), - ("Lower Body", "blue", "figure.strengthtraining.traditional", [ - ("Back Squat", 5, 5, 185), ("Romanian Deadlift", 4, 8, 155), - ("Leg Press", 4, 12, 270), ("Calf Raise", 4, 15, 90), + ("Lower Body", "green", "figure.run", [ + ("Leg Press", 4, 10, 140), ("Leg Curl", 4, 10, 40), + ("Leg Extension", 4, 10, 80), ("Calfs", 4, 15, 100), ]), ("Core", "orange", "figure.core.training", [ - ("Plank", 3, 0, 0), ("Hanging Leg Raise", 3, 12, 0), - ("Cable Crunch", 3, 15, 50), + ("Plank", 3, 0, 0), ("Abdominal", 4, 10, 40), + ("Rotary", 4, 10, 40), ]), ] @@ -53,8 +55,7 @@ enum ScreenshotSeed { let isDuration = e.0 == "Plank" 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, - durationTotalSeconds: isDuration ? 45 : 0, weightLastUpdated: today, - weightReminderTimeIntervalWeeks: 2) + durationTotalSeconds: isDuration ? 45 : 0) ex.split = split context.insert(ex) } @@ -62,19 +63,19 @@ enum ScreenshotSeed { let upper = splits[0] - // ---- Past finished sessions (Bench Press trend for the chart) ---------- - let benchTrend = [115, 120, 125, 130] - for (i, w) in benchTrend.enumerated() { - let date = daysAgo((benchTrend.count - i) * 4) + // ---- Past finished sessions (Lat Pull Down trend for the chart) -------- + let liftTrend = [95, 100, 105, 110] + for (i, w) in liftTrend.enumerated() { + let date = daysAgo((liftTrend.count - i) * 4) let workout = Workout(id: ULID.make(), splitID: nil, splitName: upper.0, start: date, end: date.addingTimeInterval(2400), statusRaw: WorkoutStatus.completed.rawValue, createdAt: date, updatedAt: date, jsonRelativePath: "") context.insert(workout) 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, 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) log.workout = workout context.insert(log) @@ -86,13 +87,13 @@ enum ScreenshotSeed { end: nil, statusRaw: WorkoutStatus.inProgress.rawValue, createdAt: today, updatedAt: today, jsonRelativePath: "") 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)] for (eIndex, e) in upper.3.enumerated() { let (idx, status) = progress[eIndex] 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, - durationTotalSeconds: 0, currentStateIndex: idx, completed: status == .completed, + weight: e.0 == "Lat Pull Down" ? 110 : e.3, loadType: LoadType.weight.rawValue, + durationTotalSeconds: 0, currentStateIndex: idx, statusRaw: status.rawValue, notes: nil, date: today) log.workout = workout context.insert(log) diff --git a/Shared/Utils/Date+Extensions.swift b/Shared/Utils/Date+Extensions.swift index 97cae49..e4d0728 100644 --- a/Shared/Utils/Date+Extensions.swift +++ b/Shared/Utils/Date+Extensions.swift @@ -18,8 +18,16 @@ extension Date { private static let weekdayAbbrev: DateFormatter = { 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) } + /// "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 formatDate() -> String { Self.mediumDate.string(from: self) } diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..8bdece1 --- /dev/null +++ b/TODO.md @@ -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. diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index cf5123e..e41df97 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -189,25 +189,38 @@ struct ExerciseProgressView: View { var body: some View { if startsCompleted { - CompletedPhaseView() + // Same top/bottom split as the run flow, so the form-guide figure + // stays on screen (matching the iPhone counterpart). + VStack(spacing: 0) { + CompletedPhaseView() + ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "") + } } else { flowBody } } private var flowBody: some View { - TabView(selection: $currentPage) { - ForEach(0.. doc.logs[i].currentStateIndex else { return } doc.logs[i].currentStateIndex = reached - doc.logs[i].status = WorkoutStatus.inProgress.rawValue - doc.logs[i].completed = false + doc.logs[i].transition(to: .inProgress) recomputeWorkoutStatus() doc.updatedAt = Date() @@ -520,11 +531,9 @@ struct ExerciseProgressView: View { let log = doc.logs[i] // Skip the write if it's already pristine (e.g. landing on Ready before any set). guard log.currentStateIndex != 0 - || log.status != WorkoutStatus.notStarted.rawValue - || log.completed else { return } + || log.status != WorkoutStatus.notStarted.rawValue else { return } doc.logs[i].currentStateIndex = 0 - doc.logs[i].status = WorkoutStatus.notStarted.rawValue - doc.logs[i].completed = false + doc.logs[i].transition(to: .notStarted) recomputeWorkoutStatus() doc.updatedAt = Date() onChange() @@ -533,8 +542,7 @@ struct ExerciseProgressView: View { private func completeExercise() { guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } doc.logs[i].currentStateIndex = setCount - doc.logs[i].status = WorkoutStatus.completed.rawValue - doc.logs[i].completed = true + doc.logs[i].transition(to: .completed) recomputeWorkoutStatus() doc.updatedAt = Date() diff --git a/Workouts Watch App/Views/WorkoutLogListView.swift b/Workouts Watch App/Views/WorkoutLogListView.swift index 42e006d..a97cc6e 100644 --- a/Workouts Watch App/Views/WorkoutLogListView.swift +++ b/Workouts Watch App/Views/WorkoutLogListView.swift @@ -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 { Button { showingExercisePicker = true @@ -84,7 +87,7 @@ struct WorkoutLogListView: View { ContentUnavailableView( "No Exercises", systemImage: "figure.strengthtraining.traditional", - description: Text(doc.splitID == nil + description: Text(split == nil ? "No exercises in this workout." : "Tap + to add exercises.") ) @@ -135,7 +138,6 @@ struct WorkoutLogListView: View { loadType: exercise.loadType, durationSeconds: exercise.durationTotalSeconds, currentStateIndex: 0, - completed: false, status: WorkoutStatus.notStarted.rawValue, notes: nil, date: doc.start diff --git a/Workouts/Resources/StarterSplits/Bodyweight Core.split.json b/Workouts/Resources/StarterSplits/Bodyweight Core.split.json index a387ae3..760b6ec 100644 --- a/Workouts/Resources/StarterSplits/Bodyweight Core.split.json +++ b/Workouts/Resources/StarterSplits/Bodyweight Core.split.json @@ -4,7 +4,6 @@ "createdAt" : "2020-01-01T00:00:00Z", "exercises" : [ { - "category" : 1, "durationSeconds" : 0, "id" : "01DXF6DT00AHY49HPHA00JGDQY", "loadType" : 0, @@ -12,11 +11,9 @@ "order" : 0, "reps" : 10, "sets" : 1, - "weight" : 0, - "weightReminderWeeks" : 2 + "weight" : 0 }, { - "category" : 1, "durationSeconds" : 0, "id" : "01DXF6DT0021S72PRVXDA0HCNB", "loadType" : 0, @@ -24,11 +21,9 @@ "order" : 1, "reps" : 8, "sets" : 1, - "weight" : 0, - "weightReminderWeeks" : 2 + "weight" : 0 }, { - "category" : 1, "durationSeconds" : 0, "id" : "01DXF6DT00K2AHTEHR3HRVKGEG", "loadType" : 0, @@ -36,11 +31,9 @@ "order" : 2, "reps" : 8, "sets" : 1, - "weight" : 0, - "weightReminderWeeks" : 2 + "weight" : 0 }, { - "category" : 0, "durationSeconds" : 45, "id" : "01DXF6DT00V256F3TPS99MCP5F", "loadType" : 2, @@ -48,11 +41,9 @@ "order" : 3, "reps" : 0, "sets" : 3, - "weight" : 0, - "weightReminderWeeks" : 2 + "weight" : 0 }, { - "category" : 0, "durationSeconds" : 30, "id" : "01DXF6DT007YMYCAYYNJEFWTQ5", "loadType" : 2, @@ -60,11 +51,9 @@ "order" : 4, "reps" : 0, "sets" : 3, - "weight" : 0, - "weightReminderWeeks" : 2 + "weight" : 0 }, { - "category" : 0, "durationSeconds" : 30, "id" : "01DXF6DT00WXCASZG1VEZRPT0Y", "loadType" : 2, @@ -72,11 +61,9 @@ "order" : 5, "reps" : 0, "sets" : 3, - "weight" : 0, - "weightReminderWeeks" : 2 + "weight" : 0 }, { - "category" : 0, "durationSeconds" : 0, "id" : "01DXF6DT00VMWCDF9QCB28WR20", "loadType" : 0, @@ -84,11 +71,9 @@ "order" : 6, "reps" : 12, "sets" : 3, - "weight" : 0, - "weightReminderWeeks" : 2 + "weight" : 0 }, { - "category" : 0, "durationSeconds" : 0, "id" : "01DXF6DT00MJH9N622QTMM7XB3", "loadType" : 0, @@ -96,11 +81,9 @@ "order" : 7, "reps" : 8, "sets" : 3, - "weight" : 0, - "weightReminderWeeks" : 2 + "weight" : 0 }, { - "category" : 0, "durationSeconds" : 0, "id" : "01DXF6DT00Z98NR23ESEEKGQH1", "loadType" : 0, @@ -108,14 +91,13 @@ "order" : 8, "reps" : 12, "sets" : 3, - "weight" : 0, - "weightReminderWeeks" : 2 + "weight" : 0 } ], "id" : "01DXF6DT00RV99WG172YFW4NKA", "name" : "Bodyweight Core", "order" : 3, - "schemaVersion" : 1, + "schemaVersion" : 2, "systemImage" : "figure.core.training", "updatedAt" : "2020-01-01T00:00:00Z" } \ No newline at end of file diff --git a/Workouts/Resources/StarterSplits/Core.split.json b/Workouts/Resources/StarterSplits/Core.split.json index 0f3bdb3..76a2d71 100644 --- a/Workouts/Resources/StarterSplits/Core.split.json +++ b/Workouts/Resources/StarterSplits/Core.split.json @@ -4,7 +4,6 @@ "createdAt" : "2020-01-01T00:00:00Z", "exercises" : [ { - "category" : 0, "durationSeconds" : 0, "id" : "01DXF6DT00C4K0NY1NRCFPB3XY", "loadType" : 1, @@ -12,11 +11,9 @@ "order" : 0, "reps" : 10, "sets" : 4, - "weight" : 0, - "weightReminderWeeks" : 2 + "weight" : 40 }, { - "category" : 0, "durationSeconds" : 0, "id" : "01DXF6DT001AY3SXTEYHA94MHZ", "loadType" : 1, @@ -24,14 +21,13 @@ "order" : 1, "reps" : 10, "sets" : 4, - "weight" : 0, - "weightReminderWeeks" : 2 + "weight" : 40 } ], "id" : "01DXF6DT001MA0TM7FHJZT098Z", "name" : "Core", "order" : 1, - "schemaVersion" : 1, + "schemaVersion" : 2, "systemImage" : "figure.core.training", "updatedAt" : "2020-01-01T00:00:00Z" } \ No newline at end of file diff --git a/Workouts/Resources/StarterSplits/Lower Body.split.json b/Workouts/Resources/StarterSplits/Lower Body.split.json index 460a15f..1c7c5dc 100644 --- a/Workouts/Resources/StarterSplits/Lower Body.split.json +++ b/Workouts/Resources/StarterSplits/Lower Body.split.json @@ -4,58 +4,70 @@ "createdAt" : "2020-01-01T00:00:00Z", "exercises" : [ { - "category" : 0, "durationSeconds" : 0, "id" : "01DXF6DT00GV3336R9C25W4X2A", "loadType" : 1, - "name" : "Abductor", + "name" : "Leg Press", "order" : 0, "reps" : 10, "sets" : 4, - "weight" : 140, - "weightReminderWeeks" : 2 + "weight" : 140 }, { - "category" : 0, "durationSeconds" : 0, "id" : "01DXF6DT00Z28DJ7PSP7Q11CJH", "loadType" : 1, - "name" : "Adductor", + "name" : "Leg Curl", "order" : 1, "reps" : 10, "sets" : 4, - "weight" : 140, - "weightReminderWeeks" : 2 + "weight" : 40 }, { - "category" : 0, "durationSeconds" : 0, "id" : "01DXF6DT00D53Q4QMWAE1BHXR6", "loadType" : 1, - "name" : "Leg Press", + "name" : "Leg Extension", "order" : 2, "reps" : 10, "sets" : 4, - "weight" : 160, - "weightReminderWeeks" : 2 + "weight" : 80 }, { - "category" : 0, "durationSeconds" : 0, "id" : "01DXF6DT006HW2KWNA5BCCDCJE", "loadType" : 1, - "name" : "Leg Curl", + "name" : "Abductor", "order" : 3, "reps" : 10, "sets" : 4, - "weight" : 70, - "weightReminderWeeks" : 2 + "weight" : 130 + }, + { + "durationSeconds" : 0, + "id" : "01DXF6DT00H5B5YD16868Y95ZQ", + "loadType" : 1, + "name" : "Adductor", + "order" : 4, + "reps" : 10, + "sets" : 4, + "weight" : 130 + }, + { + "durationSeconds" : 0, + "id" : "01DXF6DT002S88DB6NKYZS4EAW", + "loadType" : 1, + "name" : "Calfs", + "order" : 5, + "reps" : 10, + "sets" : 4, + "weight" : 100 } ], "id" : "01DXF6DT006QRF1PMGK17FV505", "name" : "Lower Body", "order" : 2, - "schemaVersion" : 1, + "schemaVersion" : 2, "systemImage" : "figure.run", "updatedAt" : "2020-01-01T00:00:00Z" } \ No newline at end of file diff --git a/Workouts/Resources/StarterSplits/Upper Body.split.json b/Workouts/Resources/StarterSplits/Upper Body.split.json index bc3d57f..8c768ce 100644 --- a/Workouts/Resources/StarterSplits/Upper Body.split.json +++ b/Workouts/Resources/StarterSplits/Upper Body.split.json @@ -4,7 +4,6 @@ "createdAt" : "2020-01-01T00:00:00Z", "exercises" : [ { - "category" : 0, "durationSeconds" : 0, "id" : "01DXF6DT00N1B90PA7K5ABWSSP", "loadType" : 1, @@ -12,50 +11,63 @@ "order" : 0, "reps" : 10, "sets" : 4, - "weight" : 110, - "weightReminderWeeks" : 2 + "weight" : 110 }, { - "category" : 0, "durationSeconds" : 0, "id" : "01DXF6DT00R81QRWGCCQG933FZ", "loadType" : 1, - "name" : "Tricep Press", + "name" : "Seated Row", "order" : 1, "reps" : 10, "sets" : 4, - "weight" : 100, - "weightReminderWeeks" : 2 + "weight" : 90 }, { - "category" : 0, "durationSeconds" : 0, "id" : "01DXF6DT000FX4241G5MPG4WG2", "loadType" : 1, - "name" : "Chest Press", + "name" : "Shoulder Press", "order" : 2, "reps" : 10, "sets" : 4, - "weight" : 40, - "weightReminderWeeks" : 2 + "weight" : 40 }, { - "category" : 0, "durationSeconds" : 0, "id" : "01DXF6DT00AD5TSVJPZZVA2S41", "loadType" : 1, - "name" : "Seated Row", + "name" : "Chest Press", "order" : 3, "reps" : 10, "sets" : 4, - "weight" : 90, - "weightReminderWeeks" : 2 + "weight" : 40 + }, + { + "durationSeconds" : 0, + "id" : "01DXF6DT006QZ6XT95301HKJT8", + "loadType" : 1, + "name" : "Tricep Press", + "order" : 4, + "reps" : 10, + "sets" : 4, + "weight" : 100 + }, + { + "durationSeconds" : 0, + "id" : "01DXF6DT009J5H4Z0XPHBT1H0K", + "loadType" : 1, + "name" : "Arm Curl", + "order" : 5, + "reps" : 10, + "sets" : 4, + "weight" : 40 } ], "id" : "01DXF6DT0038BDC2WC3EVX8ZJ5", "name" : "Upper Body", "order" : 0, - "schemaVersion" : 1, + "schemaVersion" : 2, "systemImage" : "figure.strengthtraining.traditional", "updatedAt" : "2020-01-01T00:00:00Z" } \ No newline at end of file diff --git a/Workouts/Screenshots/ScreenshotRootView.swift b/Workouts/Screenshots/ScreenshotRootView.swift index 341ae86..580a054 100644 --- a/Workouts/Screenshots/ScreenshotRootView.swift +++ b/Workouts/Screenshots/ScreenshotRootView.swift @@ -29,8 +29,10 @@ struct ScreenshotRootView: View { if let workout = activeWorkout { switch ScreenshotSeed.screen(default: "workouts") { case "exercise": - let logID = WorkoutDocument(from: workout).logs.first { $0.exerciseName == "Bench Press" }?.id + let logID = WorkoutDocument(from: workout).logs.first { $0.exerciseName == "Seated Row" }?.id NavigationStack { ExerciseView(workout: workout, logID: logID ?? "") } + case "run": + ScreenshotRunView(workout: workout) case "settings": SettingsView() default: @@ -41,4 +43,26 @@ struct ScreenshotRootView: View { } } } + +/// The paged run flow for the in-progress exercise, showing the animated form guide +/// (with machine props) — the screenshot works off its own local document copy, so +/// nothing persists. +private struct ScreenshotRunView: View { + @State private var doc: WorkoutDocument + private let logID: String + + init(workout: Workout) { + let document = WorkoutDocument(from: workout) + _doc = State(initialValue: document) + logID = document.logs.first { $0.exerciseName == "Seated Row" }?.id + ?? document.logs.first?.id ?? "" + } + + var body: some View { + NavigationStack { + ExerciseProgressView(doc: $doc, logID: logID, onChange: {}, + onLive: { _ in }, onLiveEnded: {}, incomingFrame: nil) + } + } +} #endif diff --git a/Workouts/Views/Common/CheckboxListItem.swift b/Workouts/Views/Common/CheckboxListItem.swift deleted file mode 100644 index d892bbd..0000000 --- a/Workouts/Views/Common/CheckboxListItem.swift +++ /dev/null @@ -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) - } -} diff --git a/Workouts/Views/Common/CheckboxStatus.swift b/Workouts/Views/Common/CheckboxStatus.swift index 7b063b7..2c0c1cf 100644 --- a/Workouts/Views/Common/CheckboxStatus.swift +++ b/Workouts/Views/Common/CheckboxStatus.swift @@ -19,8 +19,8 @@ enum CheckboxStatus { switch self { case .checked: .accentColor case .unchecked: .gray - case .intermediate: .gray - case .cancelled: .red + case .intermediate: .accentColor + case .cancelled: .gray } } @@ -29,7 +29,7 @@ enum CheckboxStatus { case .checked: "checkmark.circle.fill" case .unchecked: "circle" case .intermediate: "ellipsis.circle" - case .cancelled: "xmark.circle" + case .cancelled: "wrongwaysign" } } } diff --git a/Workouts/Views/Common/MachineSettingsEditor.swift b/Workouts/Views/Common/MachineSettingsEditor.swift new file mode 100644 index 0000000..bcc6318 --- /dev/null +++ b/Workouts/Views/Common/MachineSettingsEditor.swift @@ -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) } + } +} diff --git a/Workouts/Views/Exercises/ExerciseAddEditView.swift b/Workouts/Views/Exercises/ExerciseAddEditView.swift index 54c015c..57990dd 100644 --- a/Workouts/Views/Exercises/ExerciseAddEditView.swift +++ b/Workouts/Views/Exercises/ExerciseAddEditView.swift @@ -23,7 +23,6 @@ struct ExerciseAddEditView: View { // Local editable state @State private var exerciseName: String - @State private var originalWeight: Int @State private var loadType: LoadType @State private var minutes: Int @State private var seconds: Int @@ -31,9 +30,11 @@ struct ExerciseAddEditView: View { @State private var weightOnes: Int @State private var reps: Int @State private var sets: Int - @State private var weightReminderWeeks: Int - @State private var weightLastUpdated: Date? - @State private var category: ExerciseCategory + // Machine comfort settings. `machineEnabled` mirrors the optionality of the + // stored `machineSettings` — ON persists a (possibly empty) array, OFF persists + // nil. `machineSettings` holds the ordered rows while the toggle is on. + @State private var machineEnabled: Bool + @State private var machineSettings: [MachineSetting] @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb init(exercise: Exercise, split: Split) { @@ -42,7 +43,6 @@ struct ExerciseAddEditView: View { let w = exercise.weight _exerciseName = State(initialValue: exercise.name) - _originalWeight = State(initialValue: w) _loadType = State(initialValue: exercise.loadTypeEnum) _minutes = State(initialValue: exercise.durationMinutes) _seconds = State(initialValue: exercise.durationSeconds) @@ -50,9 +50,8 @@ struct ExerciseAddEditView: View { _weightOnes = State(initialValue: w % 10) _reps = State(initialValue: exercise.reps) _sets = State(initialValue: exercise.sets) - _weightReminderWeeks = State(initialValue: exercise.weightReminderTimeIntervalWeeks) - _weightLastUpdated = State(initialValue: exercise.weightLastUpdated) - _category = State(initialValue: exercise.categoryEnum) + _machineEnabled = State(initialValue: exercise.machineSettings != nil) + _machineSettings = State(initialValue: exercise.machineSettings ?? []) } var body: some View { @@ -100,19 +99,6 @@ struct ExerciseAddEditView: View { } } - Section( - header: Text("Category"), - footer: Text("The split screen groups its exercises by category, with the warm-up listed first.") - ) { - Picker("", selection: $category) { - ForEach(ExerciseCategory.displayOrder, id: \.self) { cat in - Text(cat.displayName) - .tag(cat) - } - } - .pickerStyle(.segmented) - } - Section( header: Text("Load Type"), footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.") @@ -170,16 +156,17 @@ struct ExerciseAddEditView: View { } } - Section(header: Text("Weight Increase")) { - HStack { - Text("Remind every \(weightReminderWeeks) weeks") - Spacer() - Stepper("", value: $weightReminderWeeks, in: 0...366) - } - if let lastUpdated = weightLastUpdated { - Text("Last weight change \(Date().humanTimeInterval(to: lastUpdated)) ago") + Section( + header: Text("Machine"), + footer: Text("Turn on for machine-based exercises to record comfort settings (seat height, back-rest incline, pin position…). During a workout you can adjust these and update this default.") + ) { + Toggle("Machine-based", isOn: $machineEnabled.animation()) + + if machineEnabled { + MachineSettingsEditor(settings: $machineSettings) } } + } .sheet(isPresented: $showingExercisePicker) { ExercisePickerView { exerciseNames in @@ -206,7 +193,6 @@ struct ExerciseAddEditView: View { private func saveExercise() { let newWeight = weightTens + weightOnes - let updatedWeightDate: Date? = newWeight != originalWeight ? Date() : weightLastUpdated let durationSecs = minutes * 60 + seconds var doc = SplitDocument(from: split) @@ -217,9 +203,8 @@ struct ExerciseAddEditView: View { doc.exercises[idx].weight = newWeight doc.exercises[idx].loadType = loadType.rawValue doc.exercises[idx].durationSeconds = durationSecs - doc.exercises[idx].weightLastUpdated = updatedWeightDate - doc.exercises[idx].weightReminderWeeks = weightReminderWeeks - doc.exercises[idx].category = category.rawValue + // ON → a (possibly empty) array; OFF → nil, discarding any prior settings. + doc.exercises[idx].machineSettings = machineEnabled ? machineSettings : nil } doc.updatedAt = Date() Task { await sync.save(split: doc) } diff --git a/Workouts/Views/Exercises/ExerciseListView.swift b/Workouts/Views/Exercises/ExerciseListView.swift index 769850f..3d91bda 100644 --- a/Workouts/Views/Exercises/ExerciseListView.swift +++ b/Workouts/Views/Exercises/ExerciseListView.swift @@ -226,14 +226,7 @@ struct ExerciseListView: View { guard let split else { return } let startDate = Date() let logs = split.exercisesArray.enumerated().map { i, ex in - WorkoutLogDocument( - id: ULID.make(), exerciseName: ex.name, order: i, - sets: ex.sets, reps: ex.reps, weight: ex.weight, - loadType: ex.loadType, durationSeconds: ex.durationTotalSeconds, - currentStateIndex: 0, completed: false, - status: WorkoutStatus.notStarted.rawValue, - notes: nil, date: startDate - ) + WorkoutLogDocument(planFrom: ExerciseDocument(from: ex), order: i, date: startDate) } let doc = WorkoutDocument( schemaVersion: WorkoutDocument.currentSchemaVersion, @@ -266,7 +259,7 @@ struct ExerciseListView: View { id: ULID.make(), name: exName, order: base + i, sets: 3, reps: 10, weight: 40, loadType: LoadType.weight.rawValue, - durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2 + durationSeconds: 0 ) } doc.exercises.append(contentsOf: newDocs) diff --git a/Workouts/Views/Splits/SplitDetailView.swift b/Workouts/Views/Splits/SplitDetailView.swift index 62d137d..7e8d913 100644 --- a/Workouts/Views/Splits/SplitDetailView.swift +++ b/Workouts/Views/Splits/SplitDetailView.swift @@ -63,51 +63,46 @@ struct SplitDetailView: View { .font(.caption) } - // One section per category (warm-up first). A split with no warm-ups keeps - // the single plain "Exercises" section it always had. + // Headerless — what the exercise list is needs no label; the section + // itself keeps the visual separation. if split.exercisesArray.isEmpty { - Section(header: Text("Exercises")) { + Section { Text("No exercises added yet.") Button(action: { showingExerciseAddSheet.toggle() }) { ListItem(title: "Add Exercise") } } } else { - let grouped = groupedExercises(for: split) - ForEach(grouped, id: \.category) { group in - Section(header: Text(grouped.count == 1 ? "Exercises" : group.category.displayName)) { - ForEach(group.exercises) { item in - ListItem( - title: item.name, - subtitle: item.planSummary(weightUnit: weightUnit) - ) - .swipeActions { - Button { - itemToDelete = item - } label: { - Label("Delete", systemImage: "trash") - } - .tint(.red) - Button { - itemToEdit = item - } label: { - Label("Edit", systemImage: "pencil") - } - .tint(.indigo) - } - } - .onMove { source, destination in - moveExercises(in: group.category, from: source, to: destination) - } - - if group.category == grouped.last?.category { + Section { + ForEach(split.exercisesArray) { item in + ListItem( + title: item.name, + subtitle: item.planSummary(weightUnit: weightUnit) + ) + .swipeActions { Button { - showingExerciseAddSheet = true + itemToDelete = item } label: { - ListItem(systemName: "plus.circle", title: "Add Exercise") + Label("Delete", systemImage: "trash") } + .tint(.red) + Button { + itemToEdit = item + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.indigo) } } + .onMove { source, destination in + moveExercises(from: source, to: destination) + } + + Button { + showingExerciseAddSheet = true + } label: { + ListItem(systemName: "plus.circle", title: "Add Exercise") + } } } } @@ -155,26 +150,14 @@ struct SplitDetailView: View { } } - /// Exercises bucketed by category in display order, dropping empty buckets. - private func groupedExercises(for split: Split) -> [(category: ExerciseCategory, exercises: [Exercise])] { - let all = split.exercisesArray - return ExerciseCategory.displayOrder.compactMap { category in - let members = all.filter { $0.categoryEnum == category } - return members.isEmpty ? nil : (category, members) - } - } - - /// Reorder within one category's section, then renumber globally with the - /// sections' display order (warm-ups first) as the canonical file order. Resolves - /// the current split at call time so it follows a clone-on-edit. - private func moveExercises(in category: ExerciseCategory, from source: IndexSet, to destination: Int) { + /// Reorder and renumber. Resolves the current split at call time so it + /// follows a clone-on-edit. + private func moveExercises(from source: IndexSet, to destination: Int) { guard let split else { return } - var groups = groupedExercises(for: split) - guard let gi = groups.firstIndex(where: { $0.category == category }) else { return } - groups[gi].exercises.move(fromOffsets: source, toOffset: destination) + var ordered = split.exercisesArray + ordered.move(fromOffsets: source, toOffset: destination) var doc = SplitDocument(from: split) - let ordered = groups.flatMap(\.exercises) doc.exercises = ordered.enumerated().map { i, ex in var ed = ExerciseDocument(from: ex) ed.order = i @@ -197,7 +180,7 @@ struct SplitDetailView: View { id: ULID.make(), name: exName, order: base + i, sets: 3, reps: 10, weight: 40, loadType: LoadType.weight.rawValue, - durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2 + durationSeconds: 0 ) } doc.exercises.append(contentsOf: newDocs) diff --git a/Workouts/Views/Splits/SplitListView.swift b/Workouts/Views/Splits/SplitListView.swift index e66db97..31c6fa2 100644 --- a/Workouts/Views/Splits/SplitListView.swift +++ b/Workouts/Views/Splits/SplitListView.swift @@ -10,12 +10,19 @@ import SwiftUI import SwiftData +/// The Splits library screen, pushed from Settings → Library: a two-column grid of +/// split tiles. Tapping a tile opens `SplitDetailView`; long-pressing offers Delete +/// (the sole split-delete affordance); the toolbar's + adds a new split. Starter +/// splits are seeded automatically on the true first run (`SyncEngine.autoSeedIfEmpty`), +/// so an empty library just invites creating one. struct SplitListView: View { @Environment(SyncEngine.self) private var sync - @Environment(\.modelContext) private var modelContext @Query(sort: \Split.order) private var splits: [Split] + @State private var showingAddSheet = false + @State private var splitToDelete: Split? + var body: some View { ScrollView { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { @@ -25,6 +32,13 @@ struct SplitListView: View { } label: { SplitItem(split: split) } + .contextMenu { + Button(role: .destructive) { + splitToDelete = split + } label: { + Label("Delete", systemImage: "trash") + } + } } } .padding() @@ -32,20 +46,44 @@ struct SplitListView: View { .overlay { if splits.isEmpty { ContentUnavailableView( - label: { - Label("No Splits Yet", systemImage: "dumbbell.fill") - }, - description: { - Text("Create a split to organize your workout routine.") - }, - actions: { - Button("Add Starter Splits") { - Task { await SplitSeeder.seedDefaults(into: modelContext, using: sync) } - } - .buttonStyle(.borderedProminent) - } + "No Splits Yet", + systemImage: "dumbbell.fill", + description: Text("Create a split to organize your workout routine.") ) } } + .navigationTitle("Splits") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showingAddSheet = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Add Split") + } + } + .sheet(isPresented: $showingAddSheet) { + SplitAddEditView(split: nil) + } + .confirmationDialog( + "Delete Split?", + isPresented: Binding( + get: { splitToDelete != nil }, + set: { if !$0 { splitToDelete = nil } } + ), + titleVisibility: .visible, + presenting: splitToDelete + ) { split in + Button("Delete", role: .destructive) { + Task { await sync.delete(split: split) } + splitToDelete = nil + } + Button("Cancel", role: .cancel) { + splitToDelete = nil + } + } message: { split in + Text("This will permanently delete \"\(split.name)\" and all its exercises. Past workouts are kept.") + } } } diff --git a/Workouts/Views/Splits/SplitsView.swift b/Workouts/Views/Splits/SplitsView.swift deleted file mode 100644 index 0b2a070..0000000 --- a/Workouts/Views/Splits/SplitsView.swift +++ /dev/null @@ -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) - } - } -} diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index 9e9736e..73ad2db 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -91,6 +91,11 @@ struct ExerciseProgressView: View { /// so completing the exercise from inside the flow doesn't swap the page mid-dismiss. @State private var startsCompleted: Bool + /// True when the exercise was skipped (workout ended early) when this screen opened — + /// it shows a static Skipped page instead of the timer flow. Fixed at init like + /// `startsCompleted`. + @State private var startsSkipped: Bool + init(doc: Binding, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) { self._doc = doc self.logID = logID @@ -109,6 +114,7 @@ struct ExerciseProgressView: View { let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue _startsResumed = State(initialValue: !notStarted) _startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue) + _startsSkipped = State(initialValue: log?.status == WorkoutStatus.skipped.rawValue) let base = 1 // Resume on the first unfinished set's work page (clamped to the last set). @@ -189,9 +195,21 @@ struct ExerciseProgressView: View { var body: some View { if startsCompleted { - CompletedPhaseView() - .navigationTitle(log?.exerciseName ?? "") - .navigationBarTitleDisplayMode(.inline) + // Same top/bottom split as the run flow, with the Completed badge where + // the paged flow would be, so the form-guide figure stays on screen. + VStack(spacing: 0) { + CompletedPhaseView(log: log) + ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "") + } + .navigationTitle(log?.exerciseName ?? "") + .navigationBarTitleDisplayMode(.inline) + } else if startsSkipped { + VStack(spacing: 0) { + SkippedPhaseView() + ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "") + } + .navigationTitle(log?.exerciseName ?? "") + .navigationBarTitleDisplayMode(.inline) } else { flowBody } @@ -462,7 +480,7 @@ struct ExerciseProgressView: View { private func beginExercise() { guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } guard doc.logs[i].status == WorkoutStatus.notStarted.rawValue else { return } - doc.logs[i].status = WorkoutStatus.inProgress.rawValue + doc.logs[i].transition(to: .inProgress) recomputeWorkoutStatus() doc.updatedAt = Date() onChange() @@ -476,8 +494,7 @@ struct ExerciseProgressView: View { if let i = doc.logs.firstIndex(where: { $0.id == logID }) { doc.logs[i].sets = newCount doc.logs[i].currentStateIndex = newCount - 1 // every prior set is now complete - doc.logs[i].status = WorkoutStatus.inProgress.rawValue - doc.logs[i].completed = false + doc.logs[i].transition(to: .inProgress) recomputeWorkoutStatus() doc.updatedAt = Date() onChange() @@ -504,8 +521,7 @@ struct ExerciseProgressView: View { guard reached > doc.logs[i].currentStateIndex else { return } doc.logs[i].currentStateIndex = reached - doc.logs[i].status = WorkoutStatus.inProgress.rawValue - doc.logs[i].completed = false + doc.logs[i].transition(to: .inProgress) recomputeWorkoutStatus() doc.updatedAt = Date() @@ -518,11 +534,9 @@ struct ExerciseProgressView: View { let log = doc.logs[i] // Skip the write if it's already pristine (e.g. landing on Ready before any set). guard log.currentStateIndex != 0 - || log.status != WorkoutStatus.notStarted.rawValue - || log.completed else { return } + || log.status != WorkoutStatus.notStarted.rawValue else { return } doc.logs[i].currentStateIndex = 0 - doc.logs[i].status = WorkoutStatus.notStarted.rawValue - doc.logs[i].completed = false + doc.logs[i].transition(to: .notStarted) recomputeWorkoutStatus() doc.updatedAt = Date() onChange() @@ -531,8 +545,7 @@ struct ExerciseProgressView: View { private func completeExercise() { guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } doc.logs[i].currentStateIndex = setCount - doc.logs[i].status = WorkoutStatus.completed.rawValue - doc.logs[i].completed = true + doc.logs[i].transition(to: .completed) recomputeWorkoutStatus() doc.updatedAt = Date() @@ -696,15 +709,71 @@ private extension View { // MARK: - Completed Phase -/// Shown instead of the run flow when the exercise was already completed on open. +/// Shown instead of the run flow when the exercise was already completed on open: +/// the badge, then when it was completed, what was done, and how long it took +/// (the timing lines appear only on logs that carry the timestamps). private struct CompletedPhaseView: View { + let log: WorkoutLogDocument? + + /// "4 sets × 12 reps" / "3 sets × 45 sec". + private var planSummary: String { + guard let log else { return "" } + let setsText = "\(log.sets) set\(log.sets == 1 ? "" : "s")" + if LoadType(rawValue: log.loadType) == .duration { + return "\(setsText) × \(ExerciseProgressView.durationLabel(log.durationSeconds))" + } + return "\(setsText) × \(log.reps) reps" + } + + /// Start-to-done wall time, when both timestamps were recorded. + private var elapsed: String? { + guard let log, let start = log.startedAt, let end = log.completedAt else { return nil } + let seconds = Int(end.timeIntervalSince(start)) + guard seconds > 0 else { return nil } + return ExerciseProgressView.durationLabel(seconds) + } + var body: some View { VStack(spacing: 14) { Image(systemName: "checkmark.circle.fill") - .font(.system(size: 96)) - .foregroundStyle(.green) + .font(.system(size: 96, weight: .bold)) + .foregroundStyle(Color.accentColor) Text("Completed") .font(.system(.title, design: .rounded, weight: .heavy)) + .foregroundStyle(Color.accentColor) + + if let log { + VStack(spacing: 4) { + if let completedAt = log.completedAt { + Text(completedAt.formattedRelativeDateTime()) + } + Text(planSummary) + if let elapsed { + Text("in \(elapsed)") + } + } + .font(.callout) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Skipped Phase + +/// Shown instead of the run flow when the exercise was skipped (the workout was +/// ended early with this one unfinished). Same badge treatment as Completed, +/// in gray, matching the list row's skipped icon. +private struct SkippedPhaseView: View { + var body: some View { + VStack(spacing: 14) { + Image(systemName: "wrongwaysign") + .font(.system(size: 96, weight: .bold)) + .foregroundStyle(.gray) + Text("Skipped") + .font(.system(.title, design: .rounded, weight: .heavy)) + .foregroundStyle(.gray) } .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/Workouts/Views/WorkoutLogs/ExerciseView.swift b/Workouts/Views/WorkoutLogs/ExerciseView.swift index 31ea77c..76eda64 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseView.swift @@ -12,35 +12,25 @@ import SwiftData import Charts struct ExerciseView: View { - @Environment(SyncEngine.self) private var sync @Environment(AppServices.self) private var services - @Environment(\.dismiss) private var dismiss let workout: Workout let logID: String - /// Working copy of the parent workout. Editing a log = editing this doc and - /// re-saving the whole aggregate. Driving the UI from local state (not the - /// cache entity) keeps rapid set taps from racing the file→cache update. + /// Working copy of the parent workout. Driving the UI from local state (not the + /// cache entity) lets `seedDoc` show a just-added log before the file→cache + /// round-trip catches up. @State private var doc: WorkoutDocument - @State private var progress: Int = 0 @State private var showingPlanEdit = false @State private var showingNotesEdit = false - let notStartedColor = Color.white - let completedColor = Color.green - /// `seedDoc` lets the caller hand over an in-memory document (e.g. the parent's /// working copy right after adding an exercise) so the screen doesn't wait on /// the file→cache round-trip to find the just-created log. init(workout: Workout, logID: String, seedDoc: WorkoutDocument? = nil) { self.workout = workout self.logID = logID - let initialDoc = seedDoc ?? WorkoutDocument(from: workout) - _doc = State(initialValue: initialDoc) - // Seed progress from the log so the set grid is correct on the first frame - // (onAppear also refreshes it, but that lags the initial render). - _progress = State(initialValue: initialDoc.logs.first { $0.id == logID }?.currentStateIndex ?? 0) + _doc = State(initialValue: seedDoc ?? WorkoutDocument(from: workout)) } /// The log being edited within the working doc. @@ -66,7 +56,6 @@ struct ExerciseView: View { } .onAppear { refreshDocIfNeeded() - progress = log?.currentStateIndex ?? 0 // Take over this run: the watch parks and locks it while we're editing here. services.watchBridge.setEditingWorkout(workout.id) } @@ -81,60 +70,15 @@ struct ExerciseView: View { } } - /// Pull an externally-changed workout into the working copy so the set grid and plan + /// Pull an externally-changed workout into the working copy so the plan and notes /// update without leaving and re-entering the screen. private func absorbExternalUpdate() { - let fresh = WorkoutDocument(from: workout) - doc = fresh - progress = fresh.logs.first(where: { $0.id == logID })?.currentStateIndex ?? progress + doc = WorkoutDocument(from: workout) } @ViewBuilder private func content(for log: WorkoutLogDocument) -> some View { Form { - // MARK: - Progress Section - Section(header: Text("Progress")) { - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: max(1, log.sets)), spacing: 4) { - ForEach(1...max(1, log.sets), id: \.self) { index in - ZStack { - let completed = index <= progress - let color = completed ? completedColor : notStartedColor - RoundedRectangle(cornerRadius: 8) - .fill( - LinearGradient( - gradient: Gradient(colors: [color, color.darker(by: 0.2)]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .aspectRatio(0.618, contentMode: .fit) - .shadow(radius: 2) - Text("\(index)") - .font(.title) - .fontWeight(.bold) - .foregroundColor(.primary) - .colorInvert() - } - .onTapGesture { - let totalSets = log.sets - let isLastTile = index == totalSets - let wasAlreadyAtThisProgress = progress == index - - withAnimation(.easeInOut(duration: 0.2)) { - progress = wasAlreadyAtThisProgress ? 0 : index - } - - updateLogStatus() - - // Tapping the final tile to complete returns to the list. - if isLastTile && !wasAlreadyAtThisProgress { - dismiss() - } - } - } - } - } - // MARK: - Plan Section (Read-only with Edit button) Section { PlanTilesView(log: log) @@ -186,34 +130,6 @@ struct ExerciseView: View { } } - // MARK: - Mutations - - private func updateLogStatus() { - guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } - doc.logs[i].currentStateIndex = progress - - if progress >= doc.logs[i].sets { - doc.logs[i].status = WorkoutStatus.completed.rawValue - doc.logs[i].completed = true - } else if progress > 0 { - doc.logs[i].status = WorkoutStatus.inProgress.rawValue - doc.logs[i].completed = false - } else { - doc.logs[i].status = WorkoutStatus.notStarted.rawValue - doc.logs[i].completed = false - } - - recomputeWorkoutStatus() - doc.updatedAt = Date() - let snapshot = doc - Task { await sync.save(workout: snapshot) } - } - - /// Recompute the workout's status/end from its logs. - private func recomputeWorkoutStatus() { - doc.recomputeStatusFromLogs() - } - /// If the requested log isn't in the working doc yet (just-added race), pull a /// fresh copy from the cache entity once it catches up. private func refreshDocIfNeeded() { @@ -222,21 +138,9 @@ struct ExerciseView: View { } /// Re-read the workout from the cache to absorb edits made by child sheets - /// (plan/notes) without clobbering progress edits made here. + /// (plan/notes). private func refreshDocFromCache() { - let fresh = WorkoutDocument(from: workout) - // Preserve the locally edited progress for the open log if the cache lags. - if let i = fresh.logs.firstIndex(where: { $0.id == logID }), - let mine = doc.logs.first(where: { $0.id == logID }), - fresh.logs[i].currentStateIndex != mine.currentStateIndex { - doc = fresh - doc.logs[i].currentStateIndex = mine.currentStateIndex - } else { - doc = fresh - } - if let current = log { - progress = current.currentStateIndex - } + doc = WorkoutDocument(from: workout) } } diff --git a/Workouts/Views/WorkoutLogs/PlanEditView.swift b/Workouts/Views/WorkoutLogs/PlanEditView.swift index e4be522..c64f5e7 100644 --- a/Workouts/Views/WorkoutLogs/PlanEditView.swift +++ b/Workouts/Views/WorkoutLogs/PlanEditView.swift @@ -160,10 +160,12 @@ struct PlanEditView: View { let exerciseName = doc.logs[i].exerciseName let workoutDoc = doc - // 2) Mirror the plan onto the matching exercise in the split template. + // 2) Mirror the plan onto the matching exercise in the split template, + // following the seed clone-on-edit redirect so a prior fork doesn't + // silently break the mirror. var splitDoc: SplitDocument? if let splitID = doc.splitID, - let split = CacheMapper.fetchSplit(id: splitID, in: modelContext) { + let split = CacheMapper.fetchSplit(id: sync.currentSplitID(for: splitID), in: modelContext) { var sDoc = SplitDocument(from: split) if let ei = sDoc.exercises.firstIndex(where: { $0.name == exerciseName }) { sDoc.exercises[ei].sets = sets diff --git a/Workouts/Views/WorkoutLogs/WeightProgressionChartView.swift b/Workouts/Views/WorkoutLogs/WeightProgressionChartView.swift index 0c77829..b19dd3b 100644 --- a/Workouts/Views/WorkoutLogs/WeightProgressionChartView.swift +++ b/Workouts/Views/WorkoutLogs/WeightProgressionChartView.swift @@ -20,8 +20,12 @@ struct WeightProgressionChartView: View { init(exerciseName: String) { self.exerciseName = exerciseName let name = exerciseName + // `completed` is derived (status == .completed); the predicate compares the + // stored `statusRaw` column directly, since #Predicate can't call a computed + // property. Capture the raw value in a local so the macro can embed it. + let completedRaw = WorkoutStatus.completed.rawValue _logs = Query( - filter: #Predicate { $0.exerciseName == name && $0.completed }, + filter: #Predicate { $0.exerciseName == name && $0.statusRaw == completedRaw }, sort: \WorkoutLog.date, order: .forward ) diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index 5f314fe..e4152f9 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -32,6 +32,7 @@ struct WorkoutLogListView: View { @State private var addedLog: LogRoute? @State private var logToEdit: LogRoute? @State private var summaryRoute: LogRoute? + @State private var settingsRoute: LogRoute? @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb /// Drives a programmatic push keyed by a log id, used for the freshly-added @@ -50,10 +51,12 @@ struct WorkoutLogListView: View { doc.logs.sorted { $0.order < $1.order } } - /// The split this workout was started from (for adding more exercises). + /// The split this workout was started from (for adding more exercises), + /// following the seed clone-on-edit redirect so a mid-workout fork still + /// resolves to the live clone. private var split: Split? { guard let splitID = doc.splitID else { return nil } - return CacheMapper.fetchSplit(id: splitID, in: modelContext) + return CacheMapper.fetchSplit(id: sync.currentSplitID(for: splitID), in: modelContext) } var body: some View { @@ -73,24 +76,27 @@ struct WorkoutLogListView: View { } } else { Form { - if doc.status == WorkoutStatus.completed.rawValue, doc.metrics != nil { - Section("Summary") { - WorkoutMetricsView(workout: workout) + Section { + LabeledContent("Started", value: doc.start.formattedRelativeDateTime()) + LabeledContent("Status") { + Text(statusEnum.displayName) + .fontWeight(statusEnum == .inProgress ? .bold : nil) + .foregroundStyle(statusEnum == .inProgress ? Color.accentColor : Color.secondary) } } - Section(header: Text(label)) { + Section(header: Text("Exercises")) { ForEach(sortedLogs) { log in NavigationLink { progressView(logID: log.id) } label: { - CheckboxListItem( + WorkoutLogRow( status: workoutStatus(log).checkboxStatus, - title: log.exerciseName, - subtitle: subtitleForLog(log) - ) { - cycleStatus(for: log) - } + log: log, + weightUnit: weightUnit, + onCheckboxTap: { cycleStatus(for: log) }, + onSettingsTap: { settingsRoute = LogRoute(id: log.id) } + ) } .swipeActions(edge: .leading, allowsFullSwipe: true) { Button { @@ -116,15 +122,27 @@ struct WorkoutLogListView: View { .tint(.blue) } } - .onMove(perform: moveLog) + .onMove { source, destination in + moveLog(from: source, to: destination) + } } - Section { - Button { - showingEndOptions = true - } label: { - Text("End Workout") - .frame(maxWidth: .infinity) + // The trailing slot: a running workout ends here; a finished one + // shows its summary in the button's place. + if doc.status == WorkoutStatus.completed.rawValue { + if doc.metrics != nil { + Section("Summary") { + WorkoutMetricsView(workout: workout) + } + } + } else { + Section { + Button { + showingEndOptions = true + } label: { + Text("End Workout") + .frame(maxWidth: .infinity) + } } } } @@ -199,6 +217,16 @@ struct WorkoutLogListView: View { dismiss() } } + .sheet(item: $settingsRoute) { route in + if let log = doc.logs.first(where: { $0.id == route.id }) { + MachineSettingsSheet( + exerciseName: log.exerciseName, + initialSettings: log.machineSettings ?? [] + ) { updated in + applyMachineSettings(updated, toLogID: route.id) + } + } + } } /// The paged run flow, fully wired into the live channel: it broadcasts this device's @@ -220,16 +248,8 @@ struct WorkoutLogListView: View { // MARK: - Derived - private var label: String { - if doc.status == WorkoutStatus.completed.rawValue, let end = doc.end { - if doc.start.isSameDay(as: end) { - return "\(doc.start.formattedDate())—\(end.formattedTime())" - } else { - return "\(doc.start.formattedDate())—\(end.formattedDate())" - } - } else { - return doc.start.formattedDate() - } + private var statusEnum: WorkoutStatus { + WorkoutStatus(rawValue: doc.status) ?? .notStarted } private func workoutStatus(_ log: WorkoutLogDocument) -> WorkoutStatus { @@ -253,15 +273,13 @@ struct WorkoutLogListView: View { case .completed: next = .notStarted case .skipped: next = .notStarted } - doc.logs[i].status = next.rawValue - doc.logs[i].completed = (next == .completed) + doc.logs[i].transition(to: next) save() } private func completeLog(_ log: WorkoutLogDocument) { guard let i = doc.logs.firstIndex(where: { $0.id == log.id }) else { return } - doc.logs[i].status = WorkoutStatus.completed.rawValue - doc.logs[i].completed = true + doc.logs[i].transition(to: .completed) save() } @@ -292,21 +310,7 @@ struct WorkoutLogListView: View { } doc.end = nil - let newLog = WorkoutLogDocument( - id: ULID.make(), - exerciseName: exercise.name, - order: doc.logs.count, - sets: exercise.sets, - reps: exercise.reps, - weight: exercise.weight, - loadType: exercise.loadType, - durationSeconds: exercise.durationTotalSeconds, - currentStateIndex: 0, - completed: false, - status: WorkoutStatus.notStarted.rawValue, - notes: nil, - date: now - ) + let newLog = WorkoutLogDocument(planFrom: ExerciseDocument(from: exercise), order: doc.logs.count, date: now) doc.logs.append(newLog) save() @@ -314,6 +318,44 @@ struct WorkoutLogListView: View { addedLog = LogRoute(id: newLog.id) } + /// Apply edited machine settings from the sheet: update the log's plan-time + /// snapshot (persisted with the workout), then write the same settings back to + /// the originating split's exercise as the durable default. Sequenced in one + /// task so the split's clone-on-edit repoint (which rewrites this workout's + /// `splitID`) sees the log edit already persisted rather than clobbering it. + private func applyMachineSettings(_ settings: [MachineSetting], toLogID id: String) { + guard let i = doc.logs.firstIndex(where: { $0.id == id }) else { return } + doc.logs[i].machineSettings = settings + doc.recomputeStatusFromLogs() + doc.updatedAt = Date() + + let workoutSnapshot = doc + let exerciseName = doc.logs[i].exerciseName + let splitID = doc.splitID + Task { + await sync.save(workout: workoutSnapshot) + await writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID) + } + } + + /// Push the settings onto the originating split's exercise (matched by name — + /// logs reference exercises by name only), following the seed clone-on-edit + /// redirect so the live clone is updated. Skips silently when the split is gone + /// or no exercise matches, and when nothing changed (so a pristine seed isn't + /// needlessly forked). Never caches the split's id across the save, since saving + /// a seed mints a new one. + private func writeBackMachineSettings(_ settings: [MachineSetting], exerciseName: String, splitID: String?) async { + guard let splitID else { return } + let liveID = sync.currentSplitID(for: splitID) + guard let split = CacheMapper.fetchSplit(id: liveID, in: modelContext) else { return } + var splitDoc = SplitDocument(from: split) + guard let idx = splitDoc.exercises.firstIndex(where: { $0.name == exerciseName }) else { return } + guard splitDoc.exercises[idx].machineSettings != settings else { return } + splitDoc.exercises[idx].machineSettings = settings + splitDoc.updatedAt = Date() + await sync.save(split: splitDoc) + } + /// Recompute the workout's status/end from its logs, then persist. private func save() { doc.recomputeStatusFromLogs() @@ -341,8 +383,26 @@ struct WorkoutLogListView: View { Task { await sync.delete(workout: target) } } - private func subtitleForLog(_ log: WorkoutLogDocument) -> String { - if LoadType(rawValue: log.loadType) == .duration { +} + +// MARK: - Workout Log Row + +/// One exercise line item: the status checkbox, the exercise name with a +/// prominent sets × reps line under it, and a trailing column for the load. +/// Weight only appears for weighted exercises — a bodyweight exercise +/// (`LoadType.none`) shows no load at all. +private struct WorkoutLogRow: View { + let status: CheckboxStatus + let log: WorkoutLogDocument + let weightUnit: WeightUnit + let onCheckboxTap: () -> Void + let onSettingsTap: () -> Void + + private var loadType: LoadType { LoadType(rawValue: log.loadType) ?? .none } + + /// Prominent "3 × 12" (or "3 × 5 min" for timed exercises). + private var setsAndReps: String { + if loadType == .duration { let mins = log.durationSeconds / 60 let secs = log.durationSeconds % 60 if mins > 0 && secs > 0 { @@ -353,9 +413,61 @@ struct WorkoutLogListView: View { return "\(log.sets) × \(secs) sec" } } else { - return "\(log.sets) × \(log.reps) reps × \(weightUnit.format(log.weight))" + return "\(log.sets) × \(log.reps)" } } + + private var showsWeight: Bool { loadType == .weight } + + var body: some View { + HStack(alignment: .top) { + Button { + onCheckboxTap() + } label: { + Image(systemName: status.systemName) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + .foregroundStyle(status.color) + } + .buttonStyle(.plain) + + VStack(alignment: .leading, spacing: 2) { + Text(log.exerciseName) + .font(.headline) + .foregroundColor(.primary) + Text(setsAndReps) + .font(.title3.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(.primary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + if showsWeight { + Text(weightUnit.format(log.weight)) + .font(.title3.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(.primary) + } + + // Machine comfort settings — shown for any machine-based log + // (`machineSettings != nil`), independent of load type. Tapping opens + // the editor sheet; `.plain` keeps the tap from firing the row's + // NavigationLink (same trick as the checkbox button). + if log.machineSettings != nil { + Button(action: onSettingsTap) { + Label("Settings", systemImage: "slider.horizontal.3") + .font(.footnote.weight(.medium)) + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } } // MARK: - Split Exercise Picker Sheet @@ -430,3 +542,50 @@ struct SplitExercisePickerSheet: View { } } } + +// MARK: - Machine Settings Sheet + +/// In-workout editor for a log's machine comfort settings. Only presented for a +/// machine-based log (non-nil `machineSettings`), so there's no machine toggle — +/// just the ordered rows (reusing `MachineSettingsEditor`) plus Save/Cancel. Save +/// hands the edited array back to the caller, which persists it to the log and +/// writes it back to the split's exercise as the new default. +private struct MachineSettingsSheet: View { + @Environment(\.dismiss) private var dismiss + + let exerciseName: String + @State private var settings: [MachineSetting] + let onSave: ([MachineSetting]) -> Void + + init(exerciseName: String, initialSettings: [MachineSetting], onSave: @escaping ([MachineSetting]) -> Void) { + self.exerciseName = exerciseName + self._settings = State(initialValue: initialSettings) + self.onSave = onSave + } + + var body: some View { + NavigationStack { + Form { + Section( + header: Text("Settings"), + footer: Text("Saved to this workout and set as the default for \(exerciseName) in its split.") + ) { + MachineSettingsEditor(settings: $settings) + } + } + .navigationTitle(exerciseName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + onSave(settings) + dismiss() + } + } + } + } + } +} diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift index 86dcf27..f3010f5 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift @@ -220,21 +220,7 @@ struct SplitPickerSheet: View { private func start(with split: Split) { let startDate = Date() let logs = split.exercisesArray.enumerated().map { index, exercise in - WorkoutLogDocument( - id: ULID.make(), - exerciseName: exercise.name, - order: index, - sets: exercise.sets, - reps: exercise.reps, - weight: exercise.weight, - loadType: exercise.loadType, - durationSeconds: exercise.durationTotalSeconds, - currentStateIndex: 0, - completed: false, - status: WorkoutStatus.notStarted.rawValue, - notes: nil, - date: startDate - ) + WorkoutLogDocument(planFrom: ExerciseDocument(from: exercise), order: index, date: startDate) } // A freshly started workout has no `end` — only completion stamps it. diff --git a/WorkoutsTests/SplitDocumentCodableTests.swift b/WorkoutsTests/SplitDocumentCodableTests.swift index 7761934..a633eb3 100644 --- a/WorkoutsTests/SplitDocumentCodableTests.swift +++ b/WorkoutsTests/SplitDocumentCodableTests.swift @@ -3,9 +3,12 @@ import Testing import IndieSync @testable import Workouts -/// Round-trips `SplitDocument` through `DocumentCoder` — the exact JSON codec -/// (ISO-8601 dates, sorted keys, pretty-printed) `DocumentFileStore` uses to -/// write and read every document in this app's iCloud container. +/// Round-trips `SplitDocument` (and the embedded exercise / log shapes) through +/// `DocumentCoder` — the exact JSON codec (ISO-8601 dates, sorted keys, +/// 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 { /// A whole-second epoch so ISO-8601 round-trips (no fractional-second loss). private static let stableTimestamp = Date(timeIntervalSince1970: 1_700_000_000) @@ -13,16 +16,17 @@ struct SplitDocumentCodableTests { @Test func encodeDecodeRoundTrip() throws { let exercise = ExerciseDocument( id: ULID.make(), - name: "Bench Press", + name: "Leg Press", order: 0, sets: 4, reps: 10, weight: 135, loadType: 0, durationSeconds: 0, - weightLastUpdated: Self.stableTimestamp, - weightReminderWeeks: 4, - category: ExerciseCategory.warmup.rawValue + machineSettings: [ + MachineSetting(name: "Seat Height", value: "4"), + MachineSetting(name: "Back Rest Incline", value: "45°") + ] ) let original = SplitDocument( schemaVersion: SplitDocument.currentSchemaVersion, @@ -41,12 +45,65 @@ struct SplitDocumentCodableTests { let decoded = try DocumentCoder.decode(SplitDocument.self, from: data) #expect(decoded == original) + #expect(decoded.schemaVersion == 2) #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 - /// optional and deliberately not schema-bumped, defaulting to the main circuit. - @Test func decodesLegacyExerciseWithoutCategory() throws { + /// An `ExerciseDocument` with no machine settings encodes without a + /// `machineSettings` key (nil optionals are omitted) and decodes back to nil — + /// 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 + /// cache→document mapper) round-trips cleanly at v2. + @Test func decodesLegacyV1SplitAndRoundTripsAtV2() throws { let json = """ { "schemaVersion": 1, @@ -66,11 +123,52 @@ struct SplitDocumentCodableTests { "weight": 135, "loadType": 1, "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)) - #expect(decoded.exercises.first?.category == nil) + var decoded = try DocumentCoder.decode(SplitDocument.self, from: Data(json.utf8)) + // 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) } } diff --git a/WorkoutsTests/WorkoutLogSnapshotTests.swift b/WorkoutsTests/WorkoutLogSnapshotTests.swift new file mode 100644 index 0000000..d8e2f80 --- /dev/null +++ b/WorkoutsTests/WorkoutLogSnapshotTests.swift @@ -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 == []) + } +}