Files
workouts/Shared/Model/Entities.swift
T
rzen 1f2df491db Migrate the sync file layer onto the IndieSync package
Replaces the app-local copies of the extracted storage core with the
IndieSync 0.1.0 package (pinned from the new tag): ICloudFileManager ->
DocumentFileStore + TombstoneStore, ICloudFileMonitor ->
MetadataObserver (batched events with the content-date churn gate),
and the shared ULID / DocumentCoder / Tombstone / VersionedDocument
pieces. SyncEngine stays app-specific and is rewired onto the package
types; documents rename currentSchema -> currentSchemaVersion to adopt
the package protocol. ULID.make() remains as a shim minting the string
form the app keys on.

Behavior-preserving: identical JSON bytes (same coder config), same
stub wire format (kind encodes as the same strings), same soft-delete
ordering, same 30-day prune. WorkoutDocument keeps its local-calendar
month bucketing rather than adopting TimeBucketedLayout's UTC paths --
changing derivation would strand existing files. The watch target
links the package too (ULID + DocumentCoder via Shared); iOS, watchOS,
and widget targets all build.
2026-07-05 08:04:29 -04:00

258 lines
8.6 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
var weightLastUpdated: Date?
var weightReminderTimeIntervalWeeks: Int = 2
var split: Split?
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Int,
loadType: Int, durationTotalSeconds: Int, weightLastUpdated: Date?,
weightReminderTimeIntervalWeeks: Int) {
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
}
var loadTypeEnum: LoadType {
get { LoadType(rawValue: loadType) ?? .weight }
set { loadType = newValue.rawValue }
}
/// 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 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 }
}
}