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,49 @@
|
||||
import Foundation
|
||||
|
||||
/// Wire format for the iPhone↔Watch bridge. The phone is the only device that
|
||||
/// touches iCloud Drive; the watch round-trips domain documents through the phone.
|
||||
/// Payloads carry the shared `*Document` types (JSON-encoded `Data` blobs, which
|
||||
/// WatchConnectivity allows) keyed by stable ULIDs — no name/date reconciliation.
|
||||
enum WCPayload {
|
||||
static let typeKey = "type"
|
||||
static let splitsKey = "splits"
|
||||
static let workoutsKey = "workouts"
|
||||
static let workoutKey = "workout"
|
||||
|
||||
static let workoutUpdateType = "workoutUpdate" // watch → phone (one workout)
|
||||
static let requestSyncType = "requestSync" // watch → phone (please push state)
|
||||
|
||||
// MARK: - Phone → Watch (application context: latest-state-wins)
|
||||
|
||||
static func encodeState(splits: [SplitDocument], workouts: [WorkoutDocument]) -> [String: Any] {
|
||||
var dict: [String: Any] = [:]
|
||||
if let s = try? DocumentCoder.encoder.encode(splits) { dict[splitsKey] = s }
|
||||
if let w = try? DocumentCoder.encoder.encode(workouts) { dict[workoutsKey] = w }
|
||||
return dict
|
||||
}
|
||||
|
||||
static func decodeSplits(_ dict: [String: Any]) -> [SplitDocument] {
|
||||
guard let data = dict[splitsKey] as? Data else { return [] }
|
||||
return (try? DocumentCoder.decoder.decode([SplitDocument].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
static func decodeWorkouts(_ dict: [String: Any]) -> [WorkoutDocument] {
|
||||
guard let data = dict[workoutsKey] as? Data else { return [] }
|
||||
return (try? DocumentCoder.decoder.decode([WorkoutDocument].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
// MARK: - Watch → Phone (a single updated workout)
|
||||
|
||||
static func encodeWorkoutUpdate(_ workout: WorkoutDocument) -> [String: Any] {
|
||||
var dict: [String: Any] = [typeKey: workoutUpdateType]
|
||||
if let w = try? DocumentCoder.encoder.encode(workout) { dict[workoutKey] = w }
|
||||
return dict
|
||||
}
|
||||
|
||||
static func decodeWorkoutUpdate(_ dict: [String: Any]) -> WorkoutDocument? {
|
||||
guard let data = dict[workoutKey] as? Data else { return nil }
|
||||
return try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data)
|
||||
}
|
||||
|
||||
static func requestSyncMessage() -> [String: Any] { [typeKey: requestSyncType] }
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import Foundation
|
||||
|
||||
/// On-disk JSON shape for each aggregate. Independent from the SwiftData cache
|
||||
/// entities so the wire format can evolve without dragging the cache schema.
|
||||
///
|
||||
/// One document = one aggregate root:
|
||||
/// • `SplitDocument` embeds its `[ExerciseDocument]` → `Splits/<ULID>.json`
|
||||
/// • `WorkoutDocument` embeds its `[WorkoutLogDocument]` → `Workouts/YYYY/MM/<ULID>.json`
|
||||
///
|
||||
/// `schemaVersion` lets us migrate old files on read without forcing a rewrite
|
||||
/// at sync time, and forward-gates files written by a newer app version.
|
||||
/// These documents are also the wire format for the iPhone↔Watch bridge.
|
||||
|
||||
// MARK: - Split
|
||||
|
||||
struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
var schemaVersion: Int
|
||||
var id: String // ULID
|
||||
var name: String
|
||||
var color: String
|
||||
var systemImage: String
|
||||
var order: Int
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var exercises: [ExerciseDocument]
|
||||
|
||||
static let currentSchema = 1
|
||||
|
||||
var relativePath: String { "Splits/\(id).json" }
|
||||
}
|
||||
|
||||
struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
var id: String // ULID
|
||||
var name: String
|
||||
var order: Int
|
||||
var sets: Int
|
||||
var reps: Int
|
||||
var weight: Int
|
||||
var loadType: Int
|
||||
var durationSeconds: Int // total seconds (0 when not a timed exercise)
|
||||
var weightLastUpdated: Date?
|
||||
var weightReminderWeeks: Int
|
||||
}
|
||||
|
||||
// MARK: - Workout
|
||||
|
||||
struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
var schemaVersion: Int
|
||||
var id: String // ULID (chronological)
|
||||
var splitID: String?
|
||||
var splitName: String?
|
||||
var start: Date
|
||||
var end: Date?
|
||||
var status: String // WorkoutStatus raw value
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var logs: [WorkoutLogDocument]
|
||||
|
||||
static let currentSchema = 1
|
||||
|
||||
var relativePath: String { Self.relativePath(id: id, start: start) }
|
||||
|
||||
static func relativePath(id: String, start: Date) -> String {
|
||||
let cal = Calendar(identifier: .gregorian)
|
||||
let comps = cal.dateComponents([.year, .month], from: start)
|
||||
let year = comps.year ?? 1970
|
||||
let month = comps.month ?? 1
|
||||
return String(format: "Workouts/%04d/%02d/%@.json", year, month, id)
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
var id: String // ULID
|
||||
var exerciseName: String
|
||||
var order: Int
|
||||
var sets: Int
|
||||
var reps: Int
|
||||
var weight: Int
|
||||
var loadType: Int
|
||||
var durationSeconds: Int // total seconds (0 when not a timed exercise)
|
||||
var currentStateIndex: Int
|
||||
var completed: Bool
|
||||
var status: String // WorkoutStatus raw value
|
||||
var notes: String?
|
||||
var date: Date
|
||||
}
|
||||
|
||||
// MARK: - Forward-compatibility gate
|
||||
|
||||
/// A file whose `schemaVersion` exceeds the reader's `currentSchema` was written
|
||||
/// by a newer app version; it must be quarantined, not partially decoded (Codable
|
||||
/// silently drops unknown keys) and later rewritten — which would downgrade it
|
||||
/// and lose the newer fields.
|
||||
protocol VersionedDocument {
|
||||
var schemaVersion: Int { get }
|
||||
static var currentSchema: Int { get }
|
||||
}
|
||||
|
||||
extension SplitDocument: VersionedDocument {}
|
||||
extension WorkoutDocument: VersionedDocument {}
|
||||
|
||||
extension VersionedDocument {
|
||||
/// True if this build can safely read (and rewrite) the document.
|
||||
var isReadable: Bool { schemaVersion <= Self.currentSchema }
|
||||
}
|
||||
|
||||
// MARK: - Soft-delete tombstone
|
||||
|
||||
/// Lives at `Stubs/<id>.json` and tells every device "this aggregate has been
|
||||
/// deleted" — important when a remote device missed the `.removed` event for the
|
||||
/// live file because it was offline. After `gracePeriod` any device can prune it.
|
||||
struct Tombstone: Codable, Sendable, Equatable {
|
||||
enum Kind: String, Codable, Sendable {
|
||||
case split
|
||||
case workout
|
||||
}
|
||||
|
||||
var id: String // the aggregate's ULID
|
||||
var kind: Kind
|
||||
var deletedAt: Date
|
||||
|
||||
var relativePath: String { "Stubs/\(id).json" }
|
||||
|
||||
static let gracePeriod: TimeInterval = 30 * 24 * 60 * 60
|
||||
}
|
||||
|
||||
// MARK: - JSON coding
|
||||
|
||||
/// Shared encoder/decoder. ISO-8601 dates and sorted keys for human-readable,
|
||||
/// diff-friendly files when the container is browsed via the Files app.
|
||||
enum DocumentCoder {
|
||||
static let encoder: JSONEncoder = {
|
||||
let e = JSONEncoder()
|
||||
e.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
e.dateEncodingStrategy = .iso8601
|
||||
return e
|
||||
}()
|
||||
|
||||
static let decoder: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
d.dateDecodingStrategy = .iso8601
|
||||
return d
|
||||
}()
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
/// Completion state of a workout or an individual exercise log. Persisted as its
|
||||
/// raw string in both the JSON documents and the SwiftData cache.
|
||||
enum WorkoutStatus: String, CaseIterable, Codable, Sendable {
|
||||
case notStarted
|
||||
case inProgress
|
||||
case completed
|
||||
case skipped
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .notStarted: "Not Started"
|
||||
case .inProgress: "In Progress"
|
||||
case .completed: "Completed"
|
||||
case .skipped: "Skipped"
|
||||
}
|
||||
}
|
||||
|
||||
var name: String { displayName }
|
||||
}
|
||||
|
||||
/// How an exercise's effort is measured. Persisted as its raw `Int` value.
|
||||
enum LoadType: Int, CaseIterable, Codable, Sendable {
|
||||
case none = 0
|
||||
case weight = 1
|
||||
case duration = 2
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .none: "None"
|
||||
case .weight: "Weight"
|
||||
case .duration: "Duration"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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,
|
||||
weightLastUpdated: e.weightLastUpdated,
|
||||
weightReminderWeeks: e.weightReminderTimeIntervalWeeks)
|
||||
}
|
||||
}
|
||||
|
||||
extension SplitDocument {
|
||||
init(from split: Split) {
|
||||
self.init(schemaVersion: Self.currentSchema, 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:)))
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
completed: log.completed, status: log.statusRaw, notes: log.notes, date: log.date)
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkoutDocument {
|
||||
init(from workout: Workout) {
|
||||
self.init(schemaVersion: Self.currentSchema, 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:)))
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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,
|
||||
weightLastUpdated: d.weightLastUpdated,
|
||||
weightReminderTimeIntervalWeeks: d.weightReminderWeeks)
|
||||
}
|
||||
|
||||
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.weightLastUpdated = d.weightLastUpdated
|
||||
e.weightReminderTimeIntervalWeeks = d.weightReminderWeeks
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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, completed: d.completed, statusRaw: d.status,
|
||||
notes: d.notes, date: d.date)
|
||||
}
|
||||
|
||||
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.completed = d.completed
|
||||
l.statusRaw = d.status
|
||||
l.notes = d.notes
|
||||
l.date = d.date
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
/// Minimal ULID generator.
|
||||
///
|
||||
/// 26-character Crockford base32 string: 10 chars of millisecond timestamp +
|
||||
/// 16 chars of randomness. Lexicographic order matches chronological order,
|
||||
/// so workout documents sort newest-last by id and file listings stay ordered.
|
||||
///
|
||||
/// Spec: https://github.com/ulid/spec
|
||||
enum ULID {
|
||||
/// Crockford base32 alphabet — no I, L, O, U to avoid visual confusion.
|
||||
private static let alphabet: [Character] = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
|
||||
|
||||
/// Mints a fresh ULID for the current instant.
|
||||
static func make() -> String {
|
||||
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
var randomness = [UInt8](repeating: 0, count: 10)
|
||||
_ = randomness.withUnsafeMutableBytes { buffer in
|
||||
SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!)
|
||||
}
|
||||
return encode(timestamp: timestamp, randomness: randomness)
|
||||
}
|
||||
|
||||
/// True if `s` is a 26-char ULID using the Crockford alphabet.
|
||||
static func isValid(_ s: String) -> Bool {
|
||||
guard s.count == 26 else { return false }
|
||||
let allowed = Set(alphabet)
|
||||
return s.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
|
||||
/// Decodes the timestamp portion of a ULID, if valid.
|
||||
static func timestamp(of ulid: String) -> Date? {
|
||||
guard ulid.count == 26 else { return nil }
|
||||
let prefix = ulid.prefix(10)
|
||||
var value: UInt64 = 0
|
||||
let lookup = Dictionary(uniqueKeysWithValues: alphabet.enumerated().map { ($1, UInt64($0)) })
|
||||
for char in prefix {
|
||||
guard let digit = lookup[char] else { return nil }
|
||||
value = (value << 5) | digit
|
||||
}
|
||||
return Date(timeIntervalSince1970: Double(value) / 1000)
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
private static func encode(timestamp: UInt64, randomness: [UInt8]) -> String {
|
||||
precondition(randomness.count == 10, "ULID randomness must be 10 bytes (80 bits)")
|
||||
// 48-bit timestamp → 10 base32 chars (50 bits, top 2 unused).
|
||||
var output = [Character](repeating: "0", count: 26)
|
||||
var t = timestamp & ((1 << 48) - 1)
|
||||
for i in stride(from: 9, through: 0, by: -1) {
|
||||
output[i] = alphabet[Int(t & 0x1F)]
|
||||
t >>= 5
|
||||
}
|
||||
// 80-bit randomness → 16 base32 chars.
|
||||
var bigEnd = randomness
|
||||
for i in stride(from: 25, through: 10, by: -1) {
|
||||
var carry: UInt16 = 0
|
||||
for j in 0..<bigEnd.count {
|
||||
let combined = UInt16(bigEnd[j]) | (carry << 8)
|
||||
bigEnd[j] = UInt8(combined >> 5)
|
||||
carry = combined & 0x1F
|
||||
}
|
||||
output[i] = alphabet[Int(UInt8(carry))]
|
||||
}
|
||||
return String(output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Builds the SwiftData cache container. The iCloud Drive JSON documents are the
|
||||
/// source of truth, so this store is a rebuildable cache — wiping it is always
|
||||
/// safe; `SyncEngine` repopulates it from the container on next launch.
|
||||
enum WorkoutsModelContainer {
|
||||
/// Bump whenever the cache schema changes — wipes and rebuilds from files.
|
||||
static let currentSchemaVersion = 1
|
||||
private static let schemaVersionKey = "Workouts.persistenceSchemaVersion"
|
||||
private static let identityTokenKey = "Workouts.iCloudIdentityToken"
|
||||
|
||||
private static var storeURL: URL {
|
||||
URL.applicationSupportDirectory.appending(path: "Workouts.store")
|
||||
}
|
||||
|
||||
static func make() -> ModelContainer {
|
||||
let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||
ensureStoreDirectoryExists()
|
||||
wipeIfNeeded()
|
||||
wipeIfAccountChanged()
|
||||
|
||||
do {
|
||||
// `.none` is load-bearing: the default `.automatic` would silently
|
||||
// enable CloudKit mirroring on top of our iCloud Drive file sync.
|
||||
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
|
||||
let container = try ModelContainer(for: schema, configurations: [config])
|
||||
UserDefaults.standard.set(currentSchemaVersion, forKey: schemaVersionKey)
|
||||
return container
|
||||
} catch {
|
||||
print("Workouts: ModelContainer creation failed at \(storeURL.path): \(error). Falling back to in-memory.")
|
||||
}
|
||||
|
||||
do {
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
return try ModelContainer(for: schema, configurations: [config])
|
||||
} catch {
|
||||
fatalError("Workouts: could not create even an in-memory ModelContainer: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the current iCloud identity token as the cache's owner. Call after
|
||||
/// rebuilding the cache for a newly-signed-in account.
|
||||
static func persistCurrentIdentityToken() {
|
||||
UserDefaults.standard.set(currentIdentityTokenData(), forKey: identityTokenKey)
|
||||
}
|
||||
|
||||
// MARK: - Wipe helpers
|
||||
|
||||
private static func ensureStoreDirectoryExists() {
|
||||
let parent = storeURL.deletingLastPathComponent()
|
||||
try? FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private static func wipeIfNeeded() {
|
||||
let stored = UserDefaults.standard.integer(forKey: schemaVersionKey)
|
||||
guard stored < currentSchemaVersion else { return }
|
||||
wipeStore()
|
||||
}
|
||||
|
||||
/// Wipes the cache when the signed-in iCloud account differs from the one the
|
||||
/// cache was built for. A nil token is "not ready yet", not "no account" — so
|
||||
/// only compare once a token resolves; mid-session changes are handled live.
|
||||
private static func wipeIfAccountChanged() {
|
||||
guard let current = currentIdentityTokenData() else { return }
|
||||
let stored = UserDefaults.standard.data(forKey: identityTokenKey)
|
||||
guard current != stored else { return }
|
||||
wipeStore()
|
||||
UserDefaults.standard.set(current, forKey: identityTokenKey)
|
||||
}
|
||||
|
||||
private static func currentIdentityTokenData() -> Data? {
|
||||
guard let token = FileManager.default.ubiquityIdentityToken else { return nil }
|
||||
return try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: false)
|
||||
}
|
||||
|
||||
private static func wipeStore() {
|
||||
let base = storeURL
|
||||
for url in [base, URL(fileURLWithPath: base.path + "-wal"), URL(fileURLWithPath: base.path + "-shm")] {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
extension Color {
|
||||
static func color(from name: String) -> Color {
|
||||
switch name.lowercased() {
|
||||
case "red": return .red
|
||||
case "orange": return .orange
|
||||
case "yellow": return .yellow
|
||||
case "green": return .green
|
||||
case "mint": return .mint
|
||||
case "teal": return .teal
|
||||
case "cyan": return .cyan
|
||||
case "blue": return .blue
|
||||
case "indigo": return .indigo
|
||||
case "purple": return .purple
|
||||
case "pink": return .pink
|
||||
case "brown": return .brown
|
||||
default: return .indigo
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a darker shade by reducing HSB brightness (not opacity).
|
||||
func darker(by percentage: CGFloat = 0.2) -> Color {
|
||||
#if canImport(UIKit)
|
||||
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
guard UIColor(self).getHue(&h, saturation: &s, brightness: &b, alpha: &a) else { return self }
|
||||
return Color(hue: Double(h), saturation: Double(s),
|
||||
brightness: Double(max(0, b * (1 - percentage))), opacity: Double(a))
|
||||
#else
|
||||
return self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical palettes for splits (single source of truth).
|
||||
let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
|
||||
|
||||
let availableIcons = ["dumbbell.fill", "figure.strengthtraining.traditional", "figure.run", "figure.hiking", "figure.cooldown", "figure.boxing", "figure.wrestling", "figure.gymnastics", "figure.handball", "figure.core.training", "heart.fill", "bolt.fill"]
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
// Cached formatters — DateFormatter init is expensive and these are called
|
||||
// in list rows on every render pass.
|
||||
private static let shortDateTime: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateStyle = .short; f.timeStyle = .short; return f
|
||||
}()
|
||||
private static let timeOnly: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateStyle = .none; f.timeStyle = .short; return f
|
||||
}()
|
||||
private static let mediumDate: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateStyle = .medium; f.timeStyle = .none; return f
|
||||
}()
|
||||
private static let monthAbbrev: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "MMM"; return f
|
||||
}()
|
||||
private static let weekdayAbbrev: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "EEE"; return f
|
||||
}()
|
||||
|
||||
func formattedDate() -> String { Self.shortDateTime.string(from: self) }
|
||||
func formattedTime() -> String { Self.timeOnly.string(from: self) }
|
||||
func formatDate() -> String { Self.mediumDate.string(from: self) }
|
||||
|
||||
func isSameDay(as other: Date) -> Bool {
|
||||
Calendar.current.isDate(self, inSameDayAs: other)
|
||||
}
|
||||
|
||||
var abbreviatedMonth: String { Self.monthAbbrev.string(from: self) }
|
||||
var abbreviatedWeekday: String { Self.weekdayAbbrev.string(from: self) }
|
||||
var dayOfMonth: Int { Calendar.current.component(.day, from: self) }
|
||||
|
||||
func humanTimeInterval(to other: Date) -> String {
|
||||
let interval = other.timeIntervalSince(self)
|
||||
let hours = Int(interval) / 3600
|
||||
let minutes = (Int(interval) % 3600) / 60
|
||||
return hours > 0 ? "\(hours)h \(minutes)m" : "\(minutes)m"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user