An exercise's name is now a per-routine display name: a new optional libraryName on ExerciseDocument (snapshotted onto WorkoutLogDocument at plan time) keeps the link to the bundled library exercise, and every figure/info/cue lookup resolves libraryName ?? name. Deliberately not schema-bumped — an older app dropping the key only strands the figure link, same rationale as activityType. Cache schema bumped to 9 for the new columns. The picker no longer filters out exercises already in the routine (an "×N" badge marks them instead), exercise rows gain a leading Duplicate swipe that clones an entry in place, and the edit sheet gets a Name field with the library exercise shown read-only above it. Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
389 lines
14 KiB
Swift
389 lines
14 KiB
Swift
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.
|
||
|
||
extension PersistentModel {
|
||
/// True while this model is registered with a live context. Reading a persisted
|
||
/// property on a dead model traps, and any entity can die under a view that
|
||
/// retains it (remote delete, reconcile prune — the cache is rebuildable). Check
|
||
/// this before every such read: `isDeleted` alone misses the unregistered state
|
||
/// after the deletion saves, when `isDeleted` reads false again but reads still
|
||
/// trap (see `WorkoutDocument.init?(fromLive:)`).
|
||
var isLive: Bool { !isDeleted && modelContext != nil }
|
||
}
|
||
|
||
// 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?
|
||
var targetHeartRate: Int?
|
||
|
||
@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]?
|
||
// The library exercise `name` was customized from; nil → `name` IS the
|
||
// library name. Mirrors `ExerciseDocument.libraryName`.
|
||
var libraryName: String?
|
||
|
||
var routine: Routine?
|
||
|
||
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Double,
|
||
loadType: Int, durationTotalSeconds: Int, machineSettings: [MachineSetting]? = nil,
|
||
libraryName: String? = 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
|
||
self.libraryName = libraryName
|
||
}
|
||
|
||
var loadTypeEnum: LoadType {
|
||
get { LoadType(rawValue: loadType) ?? .weight }
|
||
set { loadType = newValue.rawValue }
|
||
}
|
||
|
||
/// The name to resolve figure/info/cues against — see `ExerciseDocument.libraryExerciseName`.
|
||
var libraryExerciseName: String { libraryName ?? name }
|
||
|
||
/// 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?
|
||
var targetHeartRate: Int?
|
||
|
||
@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?
|
||
// Plan-time snapshot of the exercise's `libraryName`. Mirrors
|
||
// `WorkoutLogDocument.libraryName`.
|
||
var libraryName: String?
|
||
|
||
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,
|
||
libraryName: String? = 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
|
||
self.libraryName = libraryName
|
||
}
|
||
|
||
var status: WorkoutStatus {
|
||
get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted }
|
||
set { statusRaw = newValue.rawValue }
|
||
}
|
||
|
||
var loadTypeEnum: LoadType {
|
||
get { LoadType(rawValue: loadType) ?? .weight }
|
||
set { loadType = newValue.rawValue }
|
||
}
|
||
|
||
/// The name to resolve figure/info/cues against — see `ExerciseDocument.libraryExerciseName`.
|
||
var libraryExerciseName: String { libraryName ?? exerciseName }
|
||
|
||
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?
|
||
/// Reminder time as minutes from midnight (local wall-clock); nil = no reminder.
|
||
var reminderMinutes: Int?
|
||
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, reminderMinutes: Int? = 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.reminderMinutes = reminderMinutes
|
||
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)
|
||
}
|
||
}
|