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: - Routine @Model final class Routine { @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 var restSeconds: Int? var autoAdvance: Bool? @Relationship(deleteRule: .cascade, inverse: \Exercise.routine) 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 Routine" 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: Double = 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 routine: Routine? init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Double, 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 (0–59) 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 routineID: String? var routineName: 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? /// Mirrors the document's per-log deletion tombstones (`logID → when deleted`), so the /// phone re-derives them into any `WorkoutDocument` it rebuilds and the merge stays /// deletion-safe across cache round-trips. Phone-authored; see `WorkoutMergePlanner`. var deletedLogIDs: [String: Date]? var restSeconds: Int? var autoAdvance: Bool? @Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout) var logs: [WorkoutLog] = [] init(id: String, routineID: String?, routineName: String?, start: Date, end: Date?, statusRaw: String, createdAt: Date, updatedAt: Date, jsonRelativePath: String) { self.id = id self.routineID = routineID self.routineName = routineName 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 } } /// True while this workout is an untouched draft: created by a start tap, but no /// exercise has ever left `.notStarted`. Peeking into a routine must leave no /// trace, so the start paths discard a pristine draft when the user backs out of /// it instead of persisting a phantom "in progress" workout. var isPristineDraft: Bool { status == .notStarted && logs.allSatisfy { $0.status == .notStarted } } 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: Double = 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? // Per-set actuals — stored directly as an optional Codable array, same // pattern as `machineSettings`. `nil` → legacy file / nothing recorded. var setEntries: [SetEntry]? // Mirrors the document's per-log `updatedAt` (named to dodge a future // SwiftData column collision). Reserved for H1's per-log merge; unused. var logUpdatedAt: Date? var workout: Workout? init(id: String, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Double, loadType: Int, durationTotalSeconds: Int, currentStateIndex: Int, statusRaw: String, notes: String?, date: Date, machineSettings: [MachineSetting]? = nil, startedAt: Date? = nil, completedAt: Date? = nil, setEntries: [SetEntry]? = nil, logUpdatedAt: 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 self.setEntries = setEntries self.logUpdatedAt = logUpdatedAt } 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 } } } // MARK: - Schedule @Model final class Schedule { @Attribute(.unique) var id: String = ULID.make() var routineID: String = "" var routineName: String = "" var goalRaw: String? var recurrenceRaw: String = ScheduleRecurrence.daily.rawValue var weekdays: [Int]? var date: Date? var order: Int = 0 var createdAt: Date = Date() var updatedAt: Date = Date() var jsonRelativePath: String = "" init(id: String, routineID: String, routineName: String, goalRaw: String?, recurrenceRaw: String, weekdays: [Int]?, date: Date? = nil, order: Int, createdAt: Date, updatedAt: Date, jsonRelativePath: String) { self.id = id self.routineID = routineID self.routineName = routineName self.goalRaw = goalRaw self.recurrenceRaw = recurrenceRaw self.weekdays = weekdays self.date = date self.order = order self.createdAt = createdAt self.updatedAt = updatedAt self.jsonRelativePath = jsonRelativePath } var goalKind: GoalKind? { get { goalRaw.flatMap(GoalKind.init(rawValue:)) } set { goalRaw = newValue?.rawValue } } var recurrenceEnum: ScheduleRecurrence { get { ScheduleRecurrence(rawValue: recurrenceRaw) ?? .daily } set { recurrenceRaw = newValue.rawValue } } /// Human-readable recurrence line — shares its formatting with `ScheduleDocument` /// via `ScheduleRecurrence.summary(weekdays:date:)`. var recurrenceSummary: String { recurrenceEnum.summary(weekdays: weekdays, date: date) } }