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 var weightLastUpdated: Date? var weightReminderTimeIntervalWeeks: Int = 2 var categoryRaw: Int = ExerciseCategory.main.rawValue var split: Split? init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Int, loadType: Int, durationTotalSeconds: Int, weightLastUpdated: Date?, weightReminderTimeIntervalWeeks: Int, categoryRaw: Int = ExerciseCategory.main.rawValue) { 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.weightLastUpdated = weightLastUpdated self.weightReminderTimeIntervalWeeks = weightReminderTimeIntervalWeeks self.categoryRaw = categoryRaw } var loadTypeEnum: LoadType { get { LoadType(rawValue: loadType) ?? .weight } set { loadType = newValue.rawValue } } var categoryEnum: ExerciseCategory { get { ExerciseCategory(rawValue: categoryRaw) ?? .main } set { categoryRaw = newValue.rawValue } } /// One-line plan summary for list rows — duration exercises show their hold time /// ("3 × 45 sec"), unloaded ones drop the weight ("3 × 12 reps"), weighted ones /// keep the full "3 × 10 × 40 lb". 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 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 completed: Bool = false var statusRaw: String = WorkoutStatus.notStarted.rawValue var notes: String? var date: Date = Date() var workout: Workout? init(id: String, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int, loadType: Int, durationTotalSeconds: Int, currentStateIndex: Int, completed: Bool, statusRaw: String, notes: String?, date: Date) { 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.completed = completed self.statusRaw = statusRaw self.notes = notes self.date = date } 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 } } }