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
283 lines
12 KiB
Swift
283 lines
12 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
|
|
// Stateless translation between the on-disk `*Document` wire format and the
|
|
// SwiftData cache entities. The only code that knows both shapes.
|
|
//
|
|
// • `init(from: entity)` — cache → document. Used by the write path (build a
|
|
// document to save) and the iPhone↔Watch bridge.
|
|
// • `CacheMapper.upsert*` — document → cache. Used ONLY by the metadata
|
|
// observer (the sole cache mutator) and cache rebuilds. Embedded children
|
|
// are reconciled by id (update / insert / delete) so frequent in-workout
|
|
// edits don't churn unrelated rows.
|
|
|
|
// MARK: - Cache → Document
|
|
|
|
extension ExerciseDocument {
|
|
init(from e: Exercise) {
|
|
self.init(id: e.id, name: e.name, order: e.order, sets: e.sets, reps: e.reps,
|
|
weight: e.weight, loadType: e.loadType, durationSeconds: e.durationTotalSeconds,
|
|
machineSettings: e.machineSettings, libraryName: e.libraryName)
|
|
}
|
|
}
|
|
|
|
extension RoutineDocument {
|
|
init(from routine: Routine) {
|
|
self.init(schemaVersion: Self.currentSchemaVersion, id: routine.id, name: routine.name,
|
|
color: routine.color, systemImage: routine.systemImage, order: routine.order,
|
|
createdAt: routine.createdAt, updatedAt: routine.updatedAt,
|
|
exercises: routine.exercisesArray.map(ExerciseDocument.init(from:)),
|
|
activityType: routine.activityTypeRaw,
|
|
restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance,
|
|
targetHeartRate: routine.targetHeartRate)
|
|
}
|
|
}
|
|
|
|
extension WorkoutLogDocument {
|
|
init(from log: WorkoutLog) {
|
|
self.init(id: log.id, exerciseName: log.exerciseName, order: log.order, sets: log.sets,
|
|
reps: log.reps, weight: log.weight, loadType: log.loadType,
|
|
durationSeconds: log.durationTotalSeconds, currentStateIndex: log.currentStateIndex,
|
|
status: log.statusRaw, notes: log.notes, date: log.date,
|
|
machineSettings: log.machineSettings,
|
|
startedAt: log.startedAt, completedAt: log.completedAt,
|
|
setEntries: log.setEntries, updatedAt: log.logUpdatedAt,
|
|
libraryName: log.libraryName)
|
|
}
|
|
}
|
|
|
|
extension WorkoutDocument {
|
|
init(from workout: Workout) {
|
|
self.init(schemaVersion: Self.currentSchemaVersion, id: workout.id, routineID: workout.routineID,
|
|
routineName: workout.routineName, start: workout.start, end: workout.end,
|
|
status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt,
|
|
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)),
|
|
metrics: workout.metrics,
|
|
deletedLogIDs: workout.deletedLogIDs,
|
|
restSeconds: workout.restSeconds, autoAdvance: workout.autoAdvance,
|
|
targetHeartRate: workout.targetHeartRate)
|
|
}
|
|
|
|
/// Maps a *live* cache entity to a document, or `nil` when that entity has already
|
|
/// been deleted from its SwiftData context. Reading a persisted property on a
|
|
/// deleted `@Model` traps (`_InitialBackingData.getValue` → `assertionFailure`), and
|
|
/// a closure-based `NavigationLink` builds its destination view — and so runs this
|
|
/// map — eagerly for *every* row in the parent list, including during the view-graph
|
|
/// update that fires the instant a workout is deleted. Any map of an entity not
|
|
/// already known to be live must go through this guard.
|
|
///
|
|
/// `isDeleted` alone is not enough: it is true only between `context.delete()` and
|
|
/// `context.save()`. Once the deletion is saved the model becomes *unregistered* —
|
|
/// `isDeleted` reads false again, `modelContext` goes nil, and any persisted-property
|
|
/// read still traps. A `@Model` retained across time (not freshly fetched) can reach
|
|
/// this map in that state, so check both.
|
|
init?(fromLive workout: Workout) {
|
|
guard workout.isLive else { return nil }
|
|
self.init(from: workout)
|
|
}
|
|
|
|
/// An inert stand-in for a destination whose backing entity is gone: the row is
|
|
/// being removed, so its detail screen is never actually shown. Reads nothing from
|
|
/// any entity, so it is safe to substitute for a deleted `@Model`.
|
|
static let deletedPlaceholder = WorkoutDocument(
|
|
schemaVersion: WorkoutDocument.currentSchemaVersion,
|
|
id: "",
|
|
start: Date(timeIntervalSinceReferenceDate: 0),
|
|
status: WorkoutStatus.notStarted.rawValue,
|
|
createdAt: Date(timeIntervalSinceReferenceDate: 0),
|
|
updatedAt: Date(timeIntervalSinceReferenceDate: 0),
|
|
logs: [])
|
|
}
|
|
|
|
extension ScheduleDocument {
|
|
init(from schedule: Schedule) {
|
|
self.init(schemaVersion: Self.currentSchemaVersion, id: schedule.id,
|
|
routineID: schedule.routineID, routineName: schedule.routineName,
|
|
goal: schedule.goalRaw, recurrence: schedule.recurrenceRaw,
|
|
weekdays: schedule.weekdays,
|
|
date: schedule.date, reminderMinutes: schedule.reminderMinutes,
|
|
order: schedule.order, createdAt: schedule.createdAt, updatedAt: schedule.updatedAt)
|
|
}
|
|
}
|
|
|
|
// MARK: - Document → Cache (upsert)
|
|
|
|
enum CacheMapper {
|
|
static func fetchRoutine(id: String, in context: ModelContext) -> Routine? {
|
|
var d = FetchDescriptor<Routine>(predicate: #Predicate { $0.id == id })
|
|
d.fetchLimit = 1
|
|
return try? context.fetch(d).first
|
|
}
|
|
|
|
static func fetchWorkout(id: String, in context: ModelContext) -> Workout? {
|
|
var d = FetchDescriptor<Workout>(predicate: #Predicate { $0.id == id })
|
|
d.fetchLimit = 1
|
|
return try? context.fetch(d).first
|
|
}
|
|
|
|
static func fetchSchedule(id: String, in context: ModelContext) -> Schedule? {
|
|
var d = FetchDescriptor<Schedule>(predicate: #Predicate { $0.id == id })
|
|
d.fetchLimit = 1
|
|
return try? context.fetch(d).first
|
|
}
|
|
|
|
// MARK: Routine
|
|
|
|
static func upsertRoutine(_ doc: RoutineDocument, relativePath: String, into context: ModelContext) {
|
|
let routine: Routine
|
|
if let existing = fetchRoutine(id: doc.id, in: context) {
|
|
routine = existing
|
|
} else {
|
|
routine = Routine(id: doc.id, name: doc.name, color: doc.color, systemImage: doc.systemImage,
|
|
order: doc.order, createdAt: doc.createdAt, updatedAt: doc.updatedAt,
|
|
jsonRelativePath: relativePath)
|
|
context.insert(routine)
|
|
}
|
|
routine.name = doc.name
|
|
routine.color = doc.color
|
|
routine.systemImage = doc.systemImage
|
|
routine.order = doc.order
|
|
routine.createdAt = doc.createdAt
|
|
routine.updatedAt = doc.updatedAt
|
|
routine.jsonRelativePath = relativePath
|
|
routine.activityTypeRaw = doc.activityType ?? 0
|
|
routine.restSeconds = doc.restSeconds
|
|
routine.autoAdvance = doc.autoAdvance
|
|
routine.targetHeartRate = doc.targetHeartRate
|
|
|
|
let existing = Dictionary(routine.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
|
var keep = Set<String>()
|
|
for ed in doc.exercises {
|
|
keep.insert(ed.id)
|
|
if let e = existing[ed.id] {
|
|
apply(ed, to: e)
|
|
} else {
|
|
let e = makeExercise(ed)
|
|
e.routine = routine
|
|
context.insert(e)
|
|
}
|
|
}
|
|
for e in Array(routine.exercises) where !keep.contains(e.id) {
|
|
context.delete(e)
|
|
}
|
|
}
|
|
|
|
private static func makeExercise(_ d: ExerciseDocument) -> Exercise {
|
|
Exercise(id: d.id, name: d.name, order: d.order, sets: d.sets, reps: d.reps, weight: d.weight,
|
|
loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
|
|
machineSettings: d.machineSettings, libraryName: d.libraryName)
|
|
}
|
|
|
|
private static func apply(_ d: ExerciseDocument, to e: Exercise) {
|
|
e.name = d.name
|
|
e.order = d.order
|
|
e.sets = d.sets
|
|
e.reps = d.reps
|
|
e.weight = d.weight
|
|
e.loadType = d.loadType
|
|
e.durationTotalSeconds = d.durationSeconds
|
|
e.machineSettings = d.machineSettings
|
|
e.libraryName = d.libraryName
|
|
}
|
|
|
|
// MARK: Workout
|
|
|
|
static func upsertWorkout(_ doc: WorkoutDocument, relativePath: String, into context: ModelContext) {
|
|
let workout: Workout
|
|
if let existing = fetchWorkout(id: doc.id, in: context) {
|
|
workout = existing
|
|
} else {
|
|
workout = Workout(id: doc.id, routineID: doc.routineID, routineName: doc.routineName,
|
|
start: doc.start, end: doc.end, statusRaw: doc.status,
|
|
createdAt: doc.createdAt, updatedAt: doc.updatedAt,
|
|
jsonRelativePath: relativePath)
|
|
context.insert(workout)
|
|
}
|
|
workout.routineID = doc.routineID
|
|
workout.routineName = doc.routineName
|
|
workout.start = doc.start
|
|
workout.end = doc.end
|
|
workout.statusRaw = doc.status
|
|
workout.createdAt = doc.createdAt
|
|
workout.updatedAt = doc.updatedAt
|
|
workout.jsonRelativePath = relativePath
|
|
workout.metrics = doc.metrics
|
|
workout.deletedLogIDs = doc.deletedLogIDs
|
|
workout.restSeconds = doc.restSeconds
|
|
workout.autoAdvance = doc.autoAdvance
|
|
workout.targetHeartRate = doc.targetHeartRate
|
|
|
|
let existing = Dictionary(workout.logs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
|
var keep = Set<String>()
|
|
for ld in doc.logs {
|
|
keep.insert(ld.id)
|
|
if let l = existing[ld.id] {
|
|
apply(ld, to: l)
|
|
} else {
|
|
let l = makeLog(ld)
|
|
l.workout = workout
|
|
context.insert(l)
|
|
}
|
|
}
|
|
for l in Array(workout.logs) where !keep.contains(l.id) {
|
|
context.delete(l)
|
|
}
|
|
}
|
|
|
|
private static func makeLog(_ d: WorkoutLogDocument) -> WorkoutLog {
|
|
WorkoutLog(id: d.id, exerciseName: d.exerciseName, order: d.order, sets: d.sets, reps: d.reps,
|
|
weight: d.weight, loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
|
|
currentStateIndex: d.currentStateIndex, statusRaw: d.status,
|
|
notes: d.notes, date: d.date, machineSettings: d.machineSettings,
|
|
startedAt: d.startedAt, completedAt: d.completedAt,
|
|
setEntries: d.setEntries, logUpdatedAt: d.updatedAt, libraryName: d.libraryName)
|
|
}
|
|
|
|
private static func apply(_ d: WorkoutLogDocument, to l: WorkoutLog) {
|
|
l.exerciseName = d.exerciseName
|
|
l.order = d.order
|
|
l.sets = d.sets
|
|
l.reps = d.reps
|
|
l.weight = d.weight
|
|
l.loadType = d.loadType
|
|
l.durationTotalSeconds = d.durationSeconds
|
|
l.currentStateIndex = d.currentStateIndex
|
|
l.statusRaw = d.status
|
|
l.notes = d.notes
|
|
l.date = d.date
|
|
l.machineSettings = d.machineSettings
|
|
l.startedAt = d.startedAt
|
|
l.completedAt = d.completedAt
|
|
l.setEntries = d.setEntries
|
|
l.logUpdatedAt = d.updatedAt
|
|
l.libraryName = d.libraryName
|
|
}
|
|
|
|
// MARK: Schedule
|
|
|
|
static func upsertSchedule(_ doc: ScheduleDocument, relativePath: String, into context: ModelContext) {
|
|
let schedule: Schedule
|
|
if let existing = fetchSchedule(id: doc.id, in: context) {
|
|
schedule = existing
|
|
} else {
|
|
schedule = Schedule(id: doc.id, routineID: doc.routineID, routineName: doc.routineName,
|
|
goalRaw: doc.goal, recurrenceRaw: doc.recurrence, weekdays: doc.weekdays,
|
|
date: doc.date, reminderMinutes: doc.reminderMinutes, order: doc.order,
|
|
createdAt: doc.createdAt, updatedAt: doc.updatedAt,
|
|
jsonRelativePath: relativePath)
|
|
context.insert(schedule)
|
|
}
|
|
schedule.routineID = doc.routineID
|
|
schedule.routineName = doc.routineName
|
|
schedule.goalRaw = doc.goal
|
|
schedule.recurrenceRaw = doc.recurrence
|
|
schedule.weekdays = doc.weekdays
|
|
schedule.date = doc.date
|
|
schedule.reminderMinutes = doc.reminderMinutes
|
|
schedule.order = doc.order
|
|
schedule.createdAt = doc.createdAt
|
|
schedule.updatedAt = doc.updatedAt
|
|
schedule.jsonRelativePath = relativePath
|
|
}
|
|
}
|