Files
workouts/Shared/Model/Entities.swift
T
rzen 90deb582fe Add per-split rest length and hands-free auto-advance flow
Two per-split settings, with the global Settings values as defaults:

- restSeconds: Int? — per-split rest, used between sets and (in flow) between
  exercises; nil falls back to the global default.
- autoAdvance: Bool? — flow mode: finishing an exercise rests, then opens the
  next one hands-free, all the way through the split.

Both are optional, snapshotted onto WorkoutDocument at the start sites (no live
split link), and not schema-bumped — same degradation pattern as activityType.

A thin RunFlowView wrapper (iOS + watch) owns the on-screen log and swaps it via
.id(currentLogID) on hand-off, so the per-exercise ExerciseProgressView stays
per-logID and untouched; the between-exercise rest reuses the existing .rest
countdown as the terminal page. The mirror reuses the per-logID live channel:
the wrapper suppresses the boundary .ended teardown so it follows across
exercises, and ContentView re-keys the cover on frame.logID — no sync-bridge
changes.

Morning Wake-Up ships as a flowing 45s-work / 15s-rest routine.

New Rest & Pacing section in the split editor exposes both controls.
2026-07-09 15:18:13 -04:00

301 lines
11 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
var restSeconds: Int?
var autoAdvance: Bool?
@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: 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]?
var split: Split?
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Double,
loadType: Int, durationTotalSeconds: Int, machineSettings: [MachineSetting]? = 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
}
var loadTypeEnum: LoadType {
get { LoadType(rawValue: loadType) ?? .weight }
set { loadType = 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 (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?
/// 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?
@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: 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?
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) {
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
}
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 }
}
}