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