Workouts 2.0: re-base persistence on iCloud Drive documents
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.
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user