85d0eaddbb
Replace Core Data + NSPersistentCloudKitContainer + App-Group store + WatchConnectivity dictionary sync with the QuickRabbit iCloud-documents architecture: - iCloud Drive JSON documents are the sole source of truth (one file per aggregate: Splits/<ULID>.json, Workouts/YYYY/MM/<ULID>.json), with a rebuildable SwiftData cache populated only by an NSMetadataQuery observer and a connect-time reconcile. Soft-delete tombstones; hard iCloud gate. - Shared model layer (ULID, Codable *Documents + stateless mappers, @Model cache entities, SwiftData container) compiled into both targets. - New iPhone<->Watch bridge over WatchConnectivity keyed by ULIDs; the phone is the sole writer of iCloud Drive, the watch round-trips documents. - AppServices DI + iCloud-required root gate; Swift 6 strict concurrency. - Starter splits generated on demand from the bundled YAML catalogs. - Migrate to XcodeGen (project.yml), iOS 26 / watchOS 26; CloudDocuments entitlement (drop CloudKit/App Group/aps-environment). - Duration stored as Int seconds (was a Date epoch hack); fix workout end-on-create, undismissable delete dialog, toolbar-hiding nav stacks, and the Settings placeholder. - Add README/CHANGELOG/LICENSE, .gitignore, refreshed REQUIREMENTS, and the Scripts/ TestFlight pipeline (release.sh + ASC API scripts). MARKETING_VERSION 2.0.
206 lines
6.4 KiB
Swift
206 lines
6.4 KiB
Swift
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 = ""
|
||
|
||
@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 } }
|
||
}
|
||
|
||
// 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 (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 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 = ""
|
||
|
||
@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()
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 }
|
||
}
|
||
}
|