Files
workouts/Shared/Model/Mappers.swift
T
rzen 2c1e4759ae Add machine comfort settings and tighten the document schemas
Machine-based exercises now carry ordered name/value comfort settings
(seat height, back-rest position, ...) on both ExerciseDocument and, as a
plan-time snapshot, WorkoutLogDocument — the optional array doubles as the
machine flag. Editable from the exercise editor's new Machine section and
from the workout row's settings sheet, whose mid-workout edits write back
to the originating split (workout saved first, so seed clone-on-edit
repointing can't clobber the log edit).

Schema tightening rides the same rev: splits bump to v2 (weight-reminder
fields and the unused exercise category removed), workouts to v3 (the
derived `completed` flag removed; status is the single source). Starter
seeds regenerated at v2 with unchanged ULIDs; SwiftData cache schema
bumped to rebuild. SCHEMA.md documents the shapes.

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
2026-07-06 16:29:44 -04:00

190 lines
7.5 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)
}
}
extension SplitDocument {
init(from split: Split) {
self.init(schemaVersion: Self.currentSchemaVersion, id: split.id, name: split.name,
color: split.color, systemImage: split.systemImage, order: split.order,
createdAt: split.createdAt, updatedAt: split.updatedAt,
exercises: split.exercisesArray.map(ExerciseDocument.init(from:)),
activityType: split.activityTypeRaw)
}
}
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)
}
}
extension WorkoutDocument {
init(from workout: Workout) {
self.init(schemaVersion: Self.currentSchemaVersion, id: workout.id, splitID: workout.splitID,
splitName: workout.splitName, 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)
}
}
// MARK: - Document → Cache (upsert)
enum CacheMapper {
static func fetchSplit(id: String, in context: ModelContext) -> Split? {
var d = FetchDescriptor<Split>(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
}
// MARK: Split
static func upsertSplit(_ doc: SplitDocument, relativePath: String, into context: ModelContext) {
let split: Split
if let existing = fetchSplit(id: doc.id, in: context) {
split = existing
} else {
split = Split(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(split)
}
split.name = doc.name
split.color = doc.color
split.systemImage = doc.systemImage
split.order = doc.order
split.createdAt = doc.createdAt
split.updatedAt = doc.updatedAt
split.jsonRelativePath = relativePath
split.activityTypeRaw = doc.activityType ?? 0
let existing = Dictionary(split.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.split = split
context.insert(e)
}
}
for e in Array(split.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)
}
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
}
// 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, splitID: doc.splitID, splitName: doc.splitName,
start: doc.start, end: doc.end, statusRaw: doc.status,
createdAt: doc.createdAt, updatedAt: doc.updatedAt,
jsonRelativePath: relativePath)
context.insert(workout)
}
workout.splitID = doc.splitID
workout.splitName = doc.splitName
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
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)
}
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
}
}