Files
workouts/Shared/Model/Entities.swift
T
rzen 2c1e4759ae 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
2026-07-06 16:29:44 -04:00

283 lines
9.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import IndieSync
import Foundation
import SwiftData
// SwiftData cache entities. These are a rebuildable read-through cache of the
// iCloud Drive JSON documents — never the source of truth. App code reads them
// (via @Query); all writes go through `SyncEngine` to the document files, and the
// metadata observer mirrors the files back into these entities (see Mappers).
//
// `id` is the aggregate/child ULID (stable across cache rebuilds, unlike the
// SwiftData PersistentIdentifier). Computed helpers preserve the API the views
// used against the old Core Data classes.
// MARK: - Split
@Model
final class Split {
@Attribute(.unique) var id: String = ULID.make()
var name: String = ""
var color: String = "indigo"
var systemImage: String = "dumbbell.fill"
var order: Int = 0
var createdAt: Date = Date()
var updatedAt: Date = Date()
var jsonRelativePath: String = ""
var activityTypeRaw: Int = 0
@Relationship(deleteRule: .cascade, inverse: \Exercise.split)
var exercises: [Exercise] = []
init(id: String, name: String, color: String, systemImage: String, order: Int,
createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
self.id = id
self.name = name
self.color = color
self.systemImage = systemImage
self.order = order
self.createdAt = createdAt
self.updatedAt = updatedAt
self.jsonRelativePath = jsonRelativePath
}
static let unnamed = "Unnamed Split"
var exercisesArray: [Exercise] { exercises.sorted { $0.order < $1.order } }
var activityTypeEnum: WorkoutActivityType {
get { WorkoutActivityType(rawValue: activityTypeRaw) ?? .traditionalStrength }
set { activityTypeRaw = newValue.rawValue }
}
}
// MARK: - Exercise
@Model
final class Exercise {
@Attribute(.unique) var id: String = ULID.make()
var name: String = ""
var order: Int = 0
var sets: Int = 0
var reps: Int = 0
var weight: Int = 0
var loadType: Int = LoadType.weight.rawValue
var durationTotalSeconds: Int = 0
// Machine comfort settings — stored directly as an optional Codable array
// (SwiftData persists the `[MachineSetting]` composite), mirroring how
// `Workout` stores `hrZoneSeconds` as a plain `[Double]?`. `nil` → not a
// machine exercise; empty → a machine exercise with nothing recorded yet.
var machineSettings: [MachineSetting]?
var split: Split?
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Int,
loadType: Int, durationTotalSeconds: Int, machineSettings: [MachineSetting]? = nil) {
self.id = id
self.name = name
self.order = order
self.sets = sets
self.reps = reps
self.weight = weight
self.loadType = loadType
self.durationTotalSeconds = durationTotalSeconds
self.machineSettings = machineSettings
}
var loadTypeEnum: LoadType {
get { LoadType(rawValue: loadType) ?? .weight }
set { loadType = newValue.rawValue }
}
/// One-line plan summary for list rows — duration exercises show their hold time
/// ("3 × 45 sec"), unloaded ones drop the weight ("3 × 12 reps"), weighted ones
/// keep the full "3 × 10 × 40 lb".
func planSummary(weightUnit: WeightUnit) -> String {
switch loadTypeEnum {
case .duration:
let m = durationMinutes, s = durationSeconds
let hold = m > 0 && s > 0 ? "\(m)m \(s)s" : (m > 0 ? "\(m) min" : "\(s) sec")
return "\(sets) × \(hold)"
case .none:
return "\(sets) × \(reps) reps"
case .weight:
return "\(sets) × \(reps) × \(weightUnit.format(weight))"
}
}
/// Minutes component of the total duration (for min:sec pickers).
var durationMinutes: Int {
get { durationTotalSeconds / 60 }
set { durationTotalSeconds = newValue * 60 + durationSeconds }
}
/// Seconds component (059) of the total duration.
var durationSeconds: Int {
get { durationTotalSeconds % 60 }
set { durationTotalSeconds = durationMinutes * 60 + newValue }
}
}
// MARK: - Workout
@Model
final class Workout {
@Attribute(.unique) var id: String = ULID.make()
var splitID: String?
var splitName: String?
var start: Date = Date()
var end: Date?
var statusRaw: String = WorkoutStatus.notStarted.rawValue
var createdAt: Date = Date()
var updatedAt: Date = Date()
var jsonRelativePath: String = ""
// Health metrics — flat columns mirroring `WorkoutMetrics` (SwiftData @Model
// can't store the nested Codable cleanly). Assemble via the `metrics` computed
// property. All nil until a workout finishes and a writer fills them in.
var metricActiveEnergyKcal: Double?
var metricAvgHeartRate: Double?
var metricMaxHeartRate: Double?
var metricMinHeartRate: Double?
var metricTotalVolume: Double?
var metricHRZoneSeconds: [Double]?
var metricHealthKitWorkoutUUID: String?
var metricSourceRaw: String?
var metricRecordedAt: Date?
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
var logs: [WorkoutLog] = []
init(id: String, splitID: String?, splitName: String?, start: Date, end: Date?,
statusRaw: String, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
self.id = id
self.splitID = splitID
self.splitName = splitName
self.start = start
self.end = end
self.statusRaw = statusRaw
self.createdAt = createdAt
self.updatedAt = updatedAt
self.jsonRelativePath = jsonRelativePath
}
var status: WorkoutStatus {
get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted }
set { statusRaw = newValue.rawValue }
}
var statusName: String { status.displayName }
var logsArray: [WorkoutLog] { logs.sorted { $0.order < $1.order } }
var label: String {
if status == .completed, let endDate = end {
if start.isSameDay(as: endDate) {
return "\(start.formattedDate())\(endDate.formattedTime())"
} else {
return "\(start.formattedDate())\(endDate.formattedDate())"
}
} else {
return start.formattedDate()
}
}
/// Assembles the flat `metric*` columns into a `WorkoutMetrics`, or nil when none
/// have been recorded. `source`/`recordedAt` are always set together when metrics
/// exist, so they gate presence.
var metrics: WorkoutMetrics? {
get {
guard let sourceRaw = metricSourceRaw,
let source = MetricSource(rawValue: sourceRaw),
let recordedAt = metricRecordedAt else { return nil }
return WorkoutMetrics(
activeEnergyKcal: metricActiveEnergyKcal,
avgHeartRate: metricAvgHeartRate,
maxHeartRate: metricMaxHeartRate,
minHeartRate: metricMinHeartRate,
totalVolume: metricTotalVolume,
hrZoneSeconds: metricHRZoneSeconds,
healthKitWorkoutUUID: metricHealthKitWorkoutUUID,
source: source,
recordedAt: recordedAt)
}
set {
metricActiveEnergyKcal = newValue?.activeEnergyKcal
metricAvgHeartRate = newValue?.avgHeartRate
metricMaxHeartRate = newValue?.maxHeartRate
metricMinHeartRate = newValue?.minHeartRate
metricTotalVolume = newValue?.totalVolume
metricHRZoneSeconds = newValue?.hrZoneSeconds
metricHealthKitWorkoutUUID = newValue?.healthKitWorkoutUUID
metricSourceRaw = newValue?.source.rawValue
metricRecordedAt = newValue?.recordedAt
}
}
}
// MARK: - WorkoutLog
@Model
final class WorkoutLog {
@Attribute(.unique) var id: String = ULID.make()
var exerciseName: String = ""
var order: Int = 0
var sets: Int = 0
var reps: Int = 0
var weight: Int = 0
var loadType: Int = LoadType.weight.rawValue
var durationTotalSeconds: Int = 0
var currentStateIndex: Int = 0
var statusRaw: String = WorkoutStatus.notStarted.rawValue
var notes: String?
var date: Date = Date()
// Snapshot of the exercise's machine comfort settings at plan time — stored
// directly as an optional Codable array, same pattern as `Exercise`.
var machineSettings: [MachineSetting]?
var startedAt: Date?
var completedAt: Date?
var workout: Workout?
init(id: String, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int,
loadType: Int, durationTotalSeconds: Int, currentStateIndex: Int,
statusRaw: String, notes: String?, date: Date,
machineSettings: [MachineSetting]? = nil,
startedAt: Date? = nil, completedAt: Date? = nil) {
self.id = id
self.exerciseName = exerciseName
self.order = order
self.sets = sets
self.reps = reps
self.weight = weight
self.loadType = loadType
self.durationTotalSeconds = durationTotalSeconds
self.currentStateIndex = currentStateIndex
self.statusRaw = statusRaw
self.notes = notes
self.date = date
self.machineSettings = machineSettings
self.startedAt = startedAt
self.completedAt = completedAt
}
var status: WorkoutStatus {
get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted }
set { statusRaw = newValue.rawValue }
}
var loadTypeEnum: LoadType {
get { LoadType(rawValue: loadType) ?? .weight }
set { loadType = newValue.rawValue }
}
var durationMinutes: Int {
get { durationTotalSeconds / 60 }
set { durationTotalSeconds = newValue * 60 + durationSeconds }
}
var durationSeconds: Int {
get { durationTotalSeconds % 60 }
set { durationTotalSeconds = durationMinutes * 60 + newValue }
}
}