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:
2026-06-19 14:25:27 -04:00
parent 9a881e841b
commit 85d0eaddbb
86 changed files with 3873 additions and 3755 deletions
+36
View File
@@ -0,0 +1,36 @@
import Foundation
import Observation
import SwiftData
/// Composition root for the iOS app. Owns the SwiftData cache container and the
/// iCloud sync engine, and drives the one-shot launch sequence. Injected into the
/// view tree via `.environment(...)`.
@Observable
@MainActor
final class AppServices {
let container: ModelContainer
let syncEngine: SyncEngine
let watchBridge: PhoneConnectivityBridge
private var bootstrapTask: Task<Void, Never>?
init() {
let container = WorkoutsModelContainer.make()
self.container = container
self.syncEngine = SyncEngine(container: container)
self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine)
}
/// Launch step: resolve iCloud and reconcile the cache. Idempotent repeated
/// callers await the same one-shot task.
func bootstrap() async {
if let bootstrapTask { await bootstrapTask.value; return }
let task = Task { @MainActor [weak self] in
guard let self else { return }
await self.syncEngine.connect()
self.watchBridge.activate()
}
bootstrapTask = task
await task.value
}
}
@@ -0,0 +1,92 @@
import Foundation
import SwiftData
import WatchConnectivity
/// Phone side of the iPhoneWatch bridge. The phone owns iCloud Drive; the watch
/// is a thin remote that round-trips through it:
/// Phone Watch: pushes all splits + recent workouts as the latest
/// application context whenever the cache changes (local or remote).
/// Watch Phone: receives an updated `WorkoutDocument` and applies it via the
/// SyncEngine write path (file observer cache push back).
@MainActor
final class PhoneConnectivityBridge: NSObject {
private let container: ModelContainer
private let syncEngine: SyncEngine
private var session: WCSession?
private var context: ModelContext { container.mainContext }
init(container: ModelContainer, syncEngine: SyncEngine) {
self.container = container
self.syncEngine = syncEngine
super.init()
}
func activate() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
session.activate()
self.session = session
// Push fresh state to the watch whenever the cache changes.
syncEngine.onCacheChanged = { [weak self] in self?.pushAll() }
}
/// Sends the current splits + most-recent workouts to the watch.
func pushAll() {
guard let session, session.activationState == .activated, session.isPaired,
session.isWatchAppInstalled else { return }
let splits = (try? context.fetch(FetchDescriptor<Split>(sortBy: [SortDescriptor(\.order)]))) ?? []
var wDesc = FetchDescriptor<Workout>(sortBy: [SortDescriptor(\.start, order: .reverse)])
wDesc.fetchLimit = 25
let workouts = (try? context.fetch(wDesc)) ?? []
let payload = WCPayload.encodeState(
splits: splits.map(SplitDocument.init(from:)),
workouts: workouts.map(WorkoutDocument.init(from:))
)
try? session.updateApplicationContext(payload)
}
/// Parse the (non-Sendable) WC dictionary in the nonisolated delegate context,
/// then hop to the MainActor with only Sendable values.
nonisolated private func route(_ dict: [String: Any]) {
switch dict[WCPayload.typeKey] as? String {
case WCPayload.requestSyncType:
Task { @MainActor in self.pushAll() }
case WCPayload.workoutUpdateType:
if let doc = WCPayload.decodeWorkoutUpdate(dict) {
Task { @MainActor in await self.syncEngine.ingestFromWatch(doc) }
}
default:
break
}
}
}
// MARK: - WCSessionDelegate
extension PhoneConnectivityBridge: WCSessionDelegate {
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
Task { @MainActor in self.pushAll() }
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
session.activate() // reactivate for a switched watch
}
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
if session.isReachable { Task { @MainActor in self.pushAll() } }
}
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
route(message)
}
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
route(userInfo)
}
}
@@ -1,345 +0,0 @@
//
// WatchConnectivityManager.swift
// Workouts
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
import WatchConnectivity
import CoreData
class WatchConnectivityManager: NSObject, ObservableObject {
static let shared = WatchConnectivityManager()
private var session: WCSession?
private var viewContext: NSManagedObjectContext?
override init() {
super.init()
if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
}
}
func setViewContext(_ context: NSManagedObjectContext) {
self.viewContext = context
}
// MARK: - Send Data to Watch
func syncAllData() {
guard let session = session else {
print("[WC-iOS] No WCSession")
return
}
print("[WC-iOS] Session state: \(session.activationState.rawValue), reachable: \(session.isReachable), paired: \(session.isPaired), installed: \(session.isWatchAppInstalled)")
guard session.activationState == .activated else {
print("[WC-iOS] Session not activated")
return
}
guard let context = viewContext else {
print("[WC-iOS] No view context")
return
}
context.perform {
do {
let workoutsData = try self.encodeAllWorkouts(context: context)
let splitsData = try self.encodeAllSplits(context: context)
let payload: [String: Any] = [
"workouts": workoutsData,
"splits": splitsData,
"timestamp": Date().timeIntervalSince1970
]
// Use updateApplicationContext for persistent state
try session.updateApplicationContext(payload)
print("[WC-iOS] Synced \(workoutsData.count) workouts, \(splitsData.count) splits to Watch")
} catch {
print("Failed to sync data: \(error)")
}
}
}
func sendWorkoutUpdate(_ workout: Workout) {
guard let session = session, session.activationState == .activated else { return }
do {
let workoutData = try encodeWorkout(workout)
let message: [String: Any] = [
"type": "workoutUpdate",
"workout": workoutData
]
if session.isReachable {
session.sendMessage(message, replyHandler: nil) { error in
print("Failed to send workout update: \(error)")
}
} else {
// Queue for later via application context
syncAllData()
}
} catch {
print("Failed to encode workout: \(error)")
}
}
// MARK: - Encoding
private func encodeAllWorkouts(context: NSManagedObjectContext) throws -> [[String: Any]] {
let request = Workout.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
let workouts = try context.fetch(request)
return try workouts.map { try encodeWorkout($0) }
}
private func encodeAllSplits(context: NSManagedObjectContext) throws -> [[String: Any]] {
let request = Split.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Split.order, ascending: true)]
let splits = try context.fetch(request)
return try splits.map { try encodeSplit($0) }
}
private func encodeWorkout(_ workout: Workout) throws -> [String: Any] {
var data: [String: Any] = [
"id": workout.objectID.uriRepresentation().absoluteString,
"start": workout.start.timeIntervalSince1970,
"status": workout.status.rawValue
]
if let end = workout.end {
data["end"] = end.timeIntervalSince1970
}
if let split = workout.split {
data["splitId"] = split.objectID.uriRepresentation().absoluteString
data["splitName"] = split.name
}
data["logs"] = workout.logsArray.map { encodeWorkoutLog($0) }
return data
}
private func encodeWorkoutLog(_ log: WorkoutLog) -> [String: Any] {
var data: [String: Any] = [
"id": log.objectID.uriRepresentation().absoluteString,
"exerciseName": log.exerciseName,
"date": log.date.timeIntervalSince1970,
"order": log.order,
"sets": log.sets,
"reps": log.reps,
"weight": log.weight,
"status": log.status.rawValue,
"currentStateIndex": log.currentStateIndex,
"completed": log.completed,
"loadType": log.loadType
]
if let duration = log.duration {
data["duration"] = duration.timeIntervalSince1970
}
if let notes = log.notes {
data["notes"] = notes
}
return data
}
private func encodeSplit(_ split: Split) throws -> [String: Any] {
var data: [String: Any] = [
"id": split.objectID.uriRepresentation().absoluteString,
"name": split.name,
"color": split.color,
"systemImage": split.systemImage,
"order": split.order
]
data["exercises"] = split.exercisesArray.map { encodeExercise($0) }
return data
}
private func encodeExercise(_ exercise: Exercise) -> [String: Any] {
var data: [String: Any] = [
"id": exercise.objectID.uriRepresentation().absoluteString,
"name": exercise.name,
"order": exercise.order,
"sets": exercise.sets,
"reps": exercise.reps,
"weight": exercise.weight,
"loadType": exercise.loadType
]
if let duration = exercise.duration {
data["duration"] = duration.timeIntervalSince1970
}
return data
}
}
// MARK: - WCSessionDelegate
extension WatchConnectivityManager: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("[WC-iOS] Activation failed: \(error)")
} else {
print("[WC-iOS] Activated with state: \(activationState.rawValue), paired: \(session.isPaired), installed: \(session.isWatchAppInstalled)")
// Sync data when session activates
DispatchQueue.main.async {
self.syncAllData()
}
}
}
func sessionDidBecomeInactive(_ session: WCSession) {
print("[WC-iOS] Session became inactive")
}
func sessionDidDeactivate(_ session: WCSession) {
print("[WC-iOS] Session deactivated")
// Reactivate for switching watches
session.activate()
}
func sessionReachabilityDidChange(_ session: WCSession) {
print("[WC-iOS] Reachability changed: \(session.isReachable)")
if session.isReachable {
syncAllData()
}
}
// Receive messages from Watch (for bidirectional sync)
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
print("[WC-iOS] Received message with keys: \(message.keys)")
if let type = message["type"] as? String {
switch type {
case "requestSync":
syncAllData()
case "syncFromWatch":
processWatchSync(message)
default:
break
}
}
}
// Receive user info transfers from Watch (background delivery)
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
print("[WC-iOS] Received userInfo with keys: \(userInfo.keys)")
if let type = userInfo["type"] as? String, type == "syncFromWatch" {
processWatchSync(userInfo)
}
}
// MARK: - Process Watch Sync
private func processWatchSync(_ data: [String: Any]) {
guard let viewContext = viewContext else {
print("[WC-iOS] No view context for Watch sync")
return
}
guard let workoutsData = data["workouts"] as? [[String: Any]] else {
print("[WC-iOS] No workouts in Watch sync data")
return
}
print("[WC-iOS] Processing \(workoutsData.count) workouts from Watch")
DispatchQueue.main.async {
viewContext.perform {
for workoutData in workoutsData {
self.updateWorkoutFromWatch(workoutData, context: viewContext)
}
do {
try viewContext.save()
print("[WC-iOS] Successfully saved Watch sync data")
// Refresh all objects to ensure SwiftUI observes changes
viewContext.refreshAllObjects()
} catch {
print("[WC-iOS] Failed to save Watch sync: \(error)")
}
}
}
}
private func updateWorkoutFromWatch(_ data: [String: Any], context: NSManagedObjectContext) {
guard let startInterval = data["start"] as? TimeInterval else { return }
// Find workout by start date
let request = Workout.fetchRequest()
let startDate = Date(timeIntervalSince1970: startInterval)
request.predicate = NSPredicate(
format: "start >= %@ AND start <= %@",
Date(timeIntervalSince1970: startInterval - 1) as NSDate,
Date(timeIntervalSince1970: startInterval + 1) as NSDate
)
request.fetchLimit = 1
guard let workout = try? context.fetch(request).first else {
print("[WC-iOS] Workout not found for start date: \(startDate)")
return
}
// Update workout status
if let statusRaw = data["status"] as? String,
let status = WorkoutStatus(rawValue: statusRaw) {
workout.status = status
}
if let endInterval = data["end"] as? TimeInterval {
workout.end = Date(timeIntervalSince1970: endInterval)
}
// Update logs
if let logsData = data["logs"] as? [[String: Any]] {
for logData in logsData {
updateWorkoutLogFromWatch(logData, workout: workout, context: context)
}
}
}
private func updateWorkoutLogFromWatch(_ data: [String: Any], workout: Workout, context: NSManagedObjectContext) {
guard let exerciseName = data["exerciseName"] as? String else { return }
// Find log by exercise name in this workout
guard let log = workout.logsArray.first(where: { $0.exerciseName == exerciseName }) else {
print("[WC-iOS] Log not found for exercise: \(exerciseName)")
return
}
// Update status and progress
if let statusRaw = data["status"] as? String,
let status = WorkoutStatus(rawValue: statusRaw) {
log.status = status
}
if let currentStateIndex = data["currentStateIndex"] as? Int {
log.currentStateIndex = Int32(currentStateIndex)
}
if let completed = data["completed"] as? Bool {
log.completed = completed
}
// Update other fields that might have changed
if let notes = data["notes"] as? String {
log.notes = notes
}
}
}
+3 -14
View File
@@ -1,25 +1,14 @@
//
// ContentView.swift
// Workouts
// ContentView.swift
// Workouts
//
// Created by rzen on 8/13/25 at 11:10 AM.
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import CoreData
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
var body: some View {
WorkoutLogsView()
}
}
#Preview {
ContentView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}
-77
View File
@@ -1,77 +0,0 @@
import Foundation
import CoreData
@objc(Exercise)
public class Exercise: NSManagedObject, Identifiable {
@NSManaged public var name: String
@NSManaged public var loadType: Int32
@NSManaged public var order: Int32
@NSManaged public var sets: Int32
@NSManaged public var reps: Int32
@NSManaged public var weight: Int32
@NSManaged public var duration: Date?
@NSManaged public var weightLastUpdated: Date?
@NSManaged public var weightReminderTimeIntervalWeeks: Int32
@NSManaged public var split: Split?
public var id: NSManagedObjectID { objectID }
var loadTypeEnum: LoadType {
get { LoadType(rawValue: Int(loadType)) ?? .weight }
set { loadType = Int32(newValue.rawValue) }
}
// Duration helpers for minutes/seconds conversion
var durationMinutes: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) / 60
}
set {
let seconds = durationSeconds
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
}
}
var durationSeconds: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) % 60
}
set {
let minutes = durationMinutes
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
}
}
}
// MARK: - Fetch Request
extension Exercise {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Exercise> {
return NSFetchRequest<Exercise>(entityName: "Exercise")
}
static func orderedFetchRequest(for split: Split) -> NSFetchRequest<Exercise> {
let request = fetchRequest()
request.predicate = NSPredicate(format: "split == %@", split)
request.sortDescriptors = [NSSortDescriptor(keyPath: \Exercise.order, ascending: true)]
return request
}
}
enum LoadType: Int, CaseIterable {
case none = 0
case weight = 1
case duration = 2
var name: String {
switch self {
case .none: "None"
case .weight: "Weight"
case .duration: "Duration"
}
}
}
-66
View File
@@ -1,66 +0,0 @@
import Foundation
import CoreData
import SwiftUI
@objc(Split)
public class Split: NSManagedObject, Identifiable {
@NSManaged public var name: String
@NSManaged public var color: String
@NSManaged public var systemImage: String
@NSManaged public var order: Int32
@NSManaged public var exercises: NSSet?
@NSManaged public var workouts: NSSet?
public var id: NSManagedObjectID { objectID }
static let unnamed = "Unnamed Split"
}
// MARK: - Convenience Accessors
extension Split {
var exercisesArray: [Exercise] {
let set = exercises as? Set<Exercise> ?? []
return set.sorted { $0.order < $1.order }
}
var workoutsArray: [Workout] {
let set = workouts as? Set<Workout> ?? []
return set.sorted { $0.start > $1.start }
}
func addToExercises(_ exercise: Exercise) {
let items = mutableSetValue(forKey: "exercises")
items.add(exercise)
}
func removeFromExercises(_ exercise: Exercise) {
let items = mutableSetValue(forKey: "exercises")
items.remove(exercise)
}
func addToWorkouts(_ workout: Workout) {
let items = mutableSetValue(forKey: "workouts")
items.add(workout)
}
func removeFromWorkouts(_ workout: Workout) {
let items = mutableSetValue(forKey: "workouts")
items.remove(workout)
}
}
// MARK: - Fetch Request
extension Split {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Split> {
return NSFetchRequest<Split>(entityName: "Split")
}
static func orderedFetchRequest() -> NSFetchRequest<Split> {
let request = fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Split.order, ascending: true)]
return request
}
}
-83
View File
@@ -1,83 +0,0 @@
import Foundation
import CoreData
@objc(Workout)
public class Workout: NSManagedObject, Identifiable {
@NSManaged public var start: Date
@NSManaged public var end: Date?
@NSManaged public var split: Split?
@NSManaged public var logs: NSSet?
public var id: NSManagedObjectID { objectID }
var status: WorkoutStatus {
get {
willAccessValue(forKey: "status")
let raw = primitiveValue(forKey: "status") as? String ?? "notStarted"
didAccessValue(forKey: "status")
return WorkoutStatus(rawValue: raw) ?? .notStarted
}
set {
willChangeValue(forKey: "status")
setPrimitiveValue(newValue.rawValue, forKey: "status")
didChangeValue(forKey: "status")
}
}
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()
}
}
var statusName: String {
return status.displayName
}
}
// MARK: - Convenience Accessors
extension Workout {
var logsArray: [WorkoutLog] {
let set = logs as? Set<WorkoutLog> ?? []
return set.sorted { $0.order < $1.order }
}
func addToLogs(_ log: WorkoutLog) {
let items = mutableSetValue(forKey: "logs")
items.add(log)
}
func removeFromLogs(_ log: WorkoutLog) {
let items = mutableSetValue(forKey: "logs")
items.remove(log)
}
}
// MARK: - Fetch Request
extension Workout {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Workout> {
return NSFetchRequest<Workout>(entityName: "Workout")
}
static func recentFetchRequest() -> NSFetchRequest<Workout> {
let request = fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
return request
}
static func fetchRequest(for split: Split) -> NSFetchRequest<Workout> {
let request = fetchRequest()
request.predicate = NSPredicate(format: "split == %@", split)
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
return request
}
}
-79
View File
@@ -1,79 +0,0 @@
import Foundation
import CoreData
@objc(WorkoutLog)
public class WorkoutLog: NSManagedObject, Identifiable {
@NSManaged public var date: Date
@NSManaged public var sets: Int32
@NSManaged public var reps: Int32
@NSManaged public var weight: Int32
@NSManaged public var order: Int32
@NSManaged public var exerciseName: String
@NSManaged public var currentStateIndex: Int32
@NSManaged public var elapsedSeconds: Int32
@NSManaged public var completed: Bool
@NSManaged public var loadType: Int32
@NSManaged public var duration: Date?
@NSManaged public var notes: String?
@NSManaged public var workout: Workout?
public var id: NSManagedObjectID { objectID }
var status: WorkoutStatus {
get {
willAccessValue(forKey: "status")
let raw = primitiveValue(forKey: "status") as? String ?? "notStarted"
didAccessValue(forKey: "status")
return WorkoutStatus(rawValue: raw) ?? .notStarted
}
set {
willChangeValue(forKey: "status")
setPrimitiveValue(newValue.rawValue, forKey: "status")
didChangeValue(forKey: "status")
}
}
var loadTypeEnum: LoadType {
get { LoadType(rawValue: Int(loadType)) ?? .weight }
set { loadType = Int32(newValue.rawValue) }
}
// Duration helpers for minutes/seconds conversion
var durationMinutes: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) / 60
}
set {
let seconds = durationSeconds
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
}
}
var durationSeconds: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) % 60
}
set {
let minutes = durationMinutes
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
}
}
}
// MARK: - Fetch Request
extension WorkoutLog {
@nonobjc public class func fetchRequest() -> NSFetchRequest<WorkoutLog> {
return NSFetchRequest<WorkoutLog>(entityName: "WorkoutLog")
}
static func orderedFetchRequest(for workout: Workout) -> NSFetchRequest<WorkoutLog> {
let request = fetchRequest()
request.predicate = NSPredicate(format: "workout == %@", workout)
request.sortDescriptors = [NSSortDescriptor(keyPath: \WorkoutLog.order, ascending: true)]
return request
}
}
-23
View File
@@ -1,23 +0,0 @@
import Foundation
enum WorkoutStatus: String, CaseIterable, Codable {
case notStarted = "notStarted"
case inProgress = "inProgress"
case completed = "completed"
case skipped = "skipped"
var displayName: String {
switch self {
case .notStarted:
return "Not Started"
case .inProgress:
return "In Progress"
case .completed:
return "Completed"
case .skipped:
return "Skipped"
}
}
var name: String { displayName }
}
@@ -1,131 +0,0 @@
import CoreData
import CloudKit
struct PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
// CloudKit container identifier
static let cloudKitContainerIdentifier = "iCloud.dev.rzen.indie.Workouts"
// App Group identifier for shared storage between iOS and Watch
static let appGroupIdentifier = "group.dev.rzen.indie.Workouts"
var viewContext: NSManagedObjectContext {
container.viewContext
}
// MARK: - Preview Support
static var preview: PersistenceController = {
let controller = PersistenceController(inMemory: true)
let viewContext = controller.container.viewContext
// Create sample data for previews
let split = Split(context: viewContext)
split.name = "Upper Body"
split.color = "blue"
split.systemImage = "dumbbell.fill"
split.order = 0
let exercise = Exercise(context: viewContext)
exercise.name = "Bench Press"
exercise.sets = 3
exercise.reps = 10
exercise.weight = 135
exercise.order = 0
exercise.loadType = Int32(LoadType.weight.rawValue)
exercise.split = split
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return controller
}()
// MARK: - Initialization
init(inMemory: Bool = false, cloudKitEnabled: Bool = true) {
container = NSPersistentCloudKitContainer(name: "Workouts")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("Failed to retrieve a persistent store description.")
}
if inMemory {
description.url = URL(fileURLWithPath: "/dev/null")
description.cloudKitContainerOptions = nil
} else {
// Use App Group container for shared storage between iOS and Watch
if let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier) {
let storeURL = appGroupURL.appendingPathComponent("Workouts.sqlite")
description.url = storeURL
print("Using shared App Group store at: \(storeURL)")
}
if cloudKitEnabled {
// Check if CloudKit is available before enabling
let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil
if cloudKitAvailable {
// Set CloudKit container options
let cloudKitOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: Self.cloudKitContainerIdentifier
)
description.cloudKitContainerOptions = cloudKitOptions
} else {
// CloudKit not available (not signed in, etc.)
description.cloudKitContainerOptions = nil
print("CloudKit not available - using local storage only")
}
// Enable persistent history tracking (useful even without CloudKit)
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
} else {
// CloudKit explicitly disabled
description.cloudKitContainerOptions = nil
}
}
container.loadPersistentStores { storeDescription, error in
if let error = error as NSError? {
// In production, handle this more gracefully
print("CoreData error: \(error), \(error.userInfo)")
#if DEBUG
fatalError("Unresolved error \(error), \(error.userInfo)")
#endif
}
}
// Configure view context
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
// Pin the viewContext to the current generation token
do {
try container.viewContext.setQueryGenerationFrom(.current)
} catch {
print("Failed to pin viewContext to the current generation: \(error)")
}
}
// MARK: - Save Context
func save() {
let context = container.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
let nsError = error as NSError
print("Save error: \(nsError), \(nsError.userInfo)")
}
}
}
}
+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Workouts</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Workouts</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSUbiquitousContainers</key>
<dict>
<key>iCloud.dev.rzen.indie.Workouts</key>
<dict>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUbiquitousContainerName</key>
<string>Workouts</string>
<key>NSUbiquitousContainerSupportedFolderLevels</key>
<string>Any</string>
</dict>
</dict>
</dict>
</plist>
@@ -2,21 +2,17 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.dev.rzen.indie.Workouts</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
<string>CloudDocuments</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>com.apple.security.application-groups</key>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
<string>group.dev.rzen.indie.Workouts</string>
<string>iCloud.dev.rzen.indie.Workouts</string>
</array>
</dict>
</plist>
+59
View File
@@ -0,0 +1,59 @@
import Foundation
import SwiftData
/// Builds starter Splits from the bundled YAML exercise catalog, grouped by the
/// catalog's `split` label (Upper Body / Core / Lower Body). Written on demand
/// (never auto-seeded) through the SyncEngine an empty cache at launch can't be
/// told apart from an iCloud library that simply hasn't downloaded yet.
enum SplitSeeder {
/// Visual theme per starter split group, in display order.
private static let groups: [(name: String, color: String, icon: String)] = [
("Upper Body", "blue", "figure.strengthtraining.traditional"),
("Core", "orange", "figure.core.training"),
("Lower Body", "green", "figure.run"),
]
/// Default split source catalog (most universally applicable).
private static let sourceFile = "bodyweight-starter.exercises.yaml"
/// Builds the default split documents (fresh ULIDs each call).
static func defaultSplitDocuments() -> [SplitDocument] {
let lists = ExerciseListLoader.loadExerciseLists()
guard let catalog = lists[sourceFile] else { return [] }
var docs: [SplitDocument] = []
for (order, group) in groups.enumerated() {
let items = catalog.exercises.filter { $0.split == group.name }
guard !items.isEmpty else { continue }
let exercises = items.enumerated().map { idx, item in
ExerciseDocument(
id: ULID.make(), name: item.name, order: idx,
sets: 3, reps: 10, weight: 0, loadType: LoadType.weight.rawValue,
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
)
}
docs.append(SplitDocument(
schemaVersion: SplitDocument.currentSchema, id: ULID.make(),
name: group.name, color: group.color, systemImage: group.icon, order: order,
createdAt: Date(), updatedAt: Date(), exercises: exercises
))
}
return docs
}
/// Writes any starter splits whose name doesn't already exist, appended after
/// existing splits. Idempotent against double-taps / partial prior seeds.
@MainActor
static func seedDefaults(into context: ModelContext, using sync: SyncEngine) async {
let existing = (try? context.fetch(FetchDescriptor<Split>())) ?? []
let existingNames = Set(existing.map(\.name))
let base = existing.count
let fresh = defaultSplitDocuments().filter { !existingNames.contains($0.name) }
for (offset, var doc) in fresh.enumerated() {
doc.order = base + offset
await sync.save(split: doc)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import Foundation
/// All iCloud Drive file I/O, isolated to an actor so blocking `NSFileCoordinator`
/// calls stay off the main thread. Paths are relative to the container's
/// `Documents/` directory (e.g. `Splits/<ULID>.json`, `Stubs/<ULID>.json`).
actor ICloudFileManager {
let documentsURL: URL
init(containerURL: URL) {
self.documentsURL = containerURL.appendingPathComponent("Documents", isDirectory: true)
}
/// Create the directory skeleton. Actor-isolated (NOT in init) so the
/// potentially-blocking file touch runs on the actor's executor, never main.
func prepareDirectories() {
for sub in ["Splits", "Workouts", "Stubs"] {
let url = documentsURL.appendingPathComponent(sub, isDirectory: true)
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
}
}
// MARK: - Coordinated primitives
func write(_ data: Data, to relativePath: String) throws {
let fileURL = documentsURL.appendingPathComponent(relativePath)
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
var coordError: NSError?
var writeError: Error?
NSFileCoordinator().coordinate(writingItemAt: fileURL, options: .forReplacing, error: &coordError) { url in
do { try data.write(to: url, options: .atomic) }
catch { writeError = error }
}
if let error = coordError ?? writeError { throw error }
}
func read(relativePath: String) throws -> Data {
let fileURL = documentsURL.appendingPathComponent(relativePath)
var coordError: NSError?
var result: Result<Data, Error>?
NSFileCoordinator().coordinate(readingItemAt: fileURL, options: [], error: &coordError) { url in
do { result = .success(try Data(contentsOf: url)) }
catch { result = .failure(error) }
}
if let coordError { throw coordError }
switch result {
case .success(let data): return data
case .failure(let error): throw error
case .none: throw CocoaError(.fileReadUnknown)
}
}
func remove(relativePath: String) throws {
let fileURL = documentsURL.appendingPathComponent(relativePath)
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
var coordError: NSError?
var deleteError: Error?
NSFileCoordinator().coordinate(writingItemAt: fileURL, options: .forDeleting, error: &coordError) { url in
do { try FileManager.default.removeItem(at: url) }
catch { deleteError = error }
}
if let error = coordError ?? deleteError { throw error }
}
func fileExists(_ relativePath: String) -> Bool {
FileManager.default.fileExists(atPath: documentsURL.appendingPathComponent(relativePath).path)
}
// MARK: - Soft delete
/// Writes a tombstone stub then removes the live file. Other devices learn of
/// the delete via the stub even if they were offline for the file removal.
func writeTombstoneAndRemove(_ tombstone: Tombstone, livePath: String) throws {
let data = try DocumentCoder.encoder.encode(tombstone)
try write(data, to: tombstone.relativePath)
try remove(relativePath: livePath)
}
// MARK: - Enumeration
/// Relative paths of all live data files (`Splits/`, `Workouts/`), excluding `Stubs/`.
func listDataFiles() -> [String] {
listJSON().filter { !$0.hasPrefix("Stubs/") }
}
/// All tombstones currently on disk.
func listTombstones() -> [Tombstone] {
listJSON().filter { $0.hasPrefix("Stubs/") }.compactMap { path in
guard let data = try? read(relativePath: path) else { return nil }
return try? DocumentCoder.decoder.decode(Tombstone.self, from: data)
}
}
private func listJSON() -> [String] {
let base = documentsURL.path + "/"
guard let enumerator = FileManager.default.enumerator(
at: documentsURL,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else { return [] }
var paths: [String] = []
for case let url as URL in enumerator where url.pathExtension == "json" {
let full = url.path
if full.hasPrefix(base) { paths.append(String(full.dropFirst(base.count))) }
}
return paths
}
// MARK: - Eviction
/// Triggers a download for an evicted file and polls until it materializes.
func ensureDownloaded(relativePath: String) {
let fileURL = documentsURL.appendingPathComponent(relativePath)
guard let values = try? fileURL.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey]),
let status = values.ubiquitousItemDownloadingStatus, status != .current else { return }
try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
}
}
+111
View File
@@ -0,0 +1,111 @@
import Foundation
/// Wraps a single `NSMetadataQuery` over the container's `Documents/` scope and
/// emits add/modify/remove events (paths relative to `Documents/`) via an
/// `AsyncStream`. `@MainActor` because `NSMetadataQuery` posts on, and must be
/// driven from, the main thread.
@MainActor
final class ICloudFileMonitor {
enum FileChangeEvent: Sendable {
case added(relativePath: String)
case modified(relativePath: String)
case removed(relativePath: String)
}
private let documentsURL: URL
private var query: NSMetadataQuery?
private var knownFiles: Set<String> = []
private var continuation: AsyncStream<FileChangeEvent>.Continuation?
init(documentsURL: URL) {
self.documentsURL = documentsURL
}
func events() -> AsyncStream<FileChangeEvent> {
AsyncStream { continuation in
self.continuation = continuation
continuation.onTermination = { @Sendable _ in
Task { @MainActor in self.stop() }
}
}
}
func start() {
let query = NSMetadataQuery()
query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
query.predicate = NSPredicate(format: "%K LIKE '*.json'", NSMetadataItemFSNameKey)
self.query = query
NotificationCenter.default.addObserver(
self, selector: #selector(queryDidFinishGathering(_:)),
name: .NSMetadataQueryDidFinishGathering, object: query)
NotificationCenter.default.addObserver(
self, selector: #selector(queryDidUpdate(_:)),
name: .NSMetadataQueryDidUpdate, object: query)
query.start()
}
func stop() {
query?.stop()
if let query { NotificationCenter.default.removeObserver(self, name: nil, object: query) }
query = nil
}
// MARK: - Notifications
@objc private func queryDidFinishGathering(_ notification: Notification) {
query?.disableUpdates()
defer { query?.enableUpdates() }
// Seed the baseline only; do NOT emit (else every existing file would fire
// `.added` on each launch). The engine's connect-time `reconcile()` does the
// initial import; this query then reports only live deltas after the baseline.
knownFiles = Set(currentRelativePaths())
}
@objc private func queryDidUpdate(_ notification: Notification) {
query?.disableUpdates()
defer { query?.enableUpdates() }
let currentFiles = Set(currentRelativePaths())
for file in currentFiles.subtracting(knownFiles) {
continuation?.yield(.added(relativePath: file))
}
for file in knownFiles.subtracting(currentFiles) {
continuation?.yield(.removed(relativePath: file))
}
if let updated = notification.userInfo?[NSMetadataQueryUpdateChangedItemsKey] as? [NSMetadataItem] {
for item in updated {
if let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL,
let path = relativePath(from: url), knownFiles.contains(path) {
continuation?.yield(.modified(relativePath: path))
}
}
}
knownFiles = currentFiles
}
// MARK: - Helpers
private func currentRelativePaths() -> [String] {
guard let query else { return [] }
var paths: [String] = []
for i in 0..<query.resultCount {
if let item = query.result(at: i) as? NSMetadataItem,
let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL,
let path = relativePath(from: url) {
paths.append(path)
}
}
return paths
}
private func relativePath(from url: URL) -> String? {
let base = documentsURL.path + "/"
let full = url.path
guard full.hasPrefix(base) else { return nil }
let relative = String(full.dropFirst(base.count))
return relative.hasSuffix(".json") ? relative : nil
}
}
+234
View File
@@ -0,0 +1,234 @@
import Foundation
import SwiftData
import Observation
enum ICloudStatus: Equatable {
case checking
case available
case unavailable
}
/// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is
/// the sole source of truth: every save/delete writes files only; the metadata
/// observer (and the connect-time reconcile) is the sole mutator of the cache.
@Observable
@MainActor
final class SyncEngine {
nonisolated static let containerIdentifier = "iCloud.dev.rzen.indie.Workouts"
private(set) var iCloudStatus: ICloudStatus = .checking
private(set) var isSyncing = false
/// Called after the cache changes (local or remote). The watch bridge uses
/// this to push fresh state to the watch.
var onCacheChanged: (() -> Void)?
private let modelContainer: ModelContainer
private var fileManager: ICloudFileManager?
private var monitor: ICloudFileMonitor?
private var monitorTask: Task<Void, Never>?
private var connectAttempt = 0
private var context: ModelContext { modelContainer.mainContext }
init(container: ModelContainer) {
self.modelContainer = container
}
// MARK: - Connection (deferred, time-boxed)
func connect() async {
guard iCloudStatus != .available else { return }
connectAttempt += 1
let attempt = connectAttempt
iCloudStatus = .checking
let url = await Task.detached {
FileManager.default.url(forUbiquityContainerIdentifier: Self.containerIdentifier)
}.value
guard let containerURL = url else {
if attempt == connectAttempt { iCloudStatus = .unavailable }
return
}
let fm = ICloudFileManager(containerURL: containerURL)
let timeout = Task { [weak self] in
try? await Task.sleep(for: .seconds(20))
guard let self, !Task.isCancelled else { return }
if self.iCloudStatus == .checking, attempt == self.connectAttempt {
self.iCloudStatus = .unavailable
}
}
await fm.prepareDirectories()
timeout.cancel()
guard attempt == connectAttempt else { return }
self.fileManager = fm
iCloudStatus = .available
WorkoutsModelContainer.persistCurrentIdentityToken()
await reconcile()
startMonitoring(documentsURL: fm.documentsURL)
cleanupOldStubs()
}
// MARK: - Monitoring
private func startMonitoring(documentsURL: URL) {
monitorTask?.cancel()
let monitor = ICloudFileMonitor(documentsURL: documentsURL)
self.monitor = monitor
monitor.start()
monitorTask = Task { [weak self] in
for await event in monitor.events() {
await self?.handle(event)
}
}
}
private func handle(_ event: ICloudFileMonitor.FileChangeEvent) async {
switch event {
case .added(let path), .modified(let path):
if path.hasPrefix("Stubs/") {
deleteCachedEntity(id: idFromStubPath(path))
} else {
await importFile(relativePath: path)
}
case .removed(let path):
if !path.hasPrefix("Stubs/") {
deleteCachedEntity(jsonRelativePath: path)
}
}
try? context.save()
onCacheChanged?()
}
/// Apply a workout received from the watch through the normal write path
/// (file observer cache), keeping iCloud Drive the single source of truth.
func ingestFromWatch(_ doc: WorkoutDocument) async {
await save(workout: doc)
}
// MARK: - Public CRUD (write path: files only)
func save(split doc: SplitDocument) async {
guard let fm = fileManager else { return }
do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) }
catch { print("[Sync] write failed for \(doc.relativePath): \(error)") }
// Cache updates reactively via the monitor.
}
func save(workout doc: WorkoutDocument) async {
guard let fm = fileManager else { return }
do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) }
catch { print("[Sync] write failed for \(doc.relativePath): \(error)") }
}
func delete(split: Split) async {
await softDelete(id: split.id, kind: .split, livePath: split.jsonRelativePath)
}
func delete(workout: Workout) async {
await softDelete(id: workout.id, kind: .workout, livePath: workout.jsonRelativePath)
}
private func softDelete(id: String, kind: Tombstone.Kind, livePath: String) async {
guard let fm = fileManager else { return }
let tombstone = Tombstone(id: id, kind: kind, deletedAt: Date())
do {
try await fm.writeTombstoneAndRemove(tombstone, livePath: livePath)
} catch {
print("[Sync] delete failed for \(id): \(error)")
}
}
// MARK: - Import / reconcile
private func importFile(relativePath: String) async {
guard let fm = fileManager else { return }
guard let data = try? await fm.read(relativePath: relativePath) else { return }
if relativePath.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { return }
if await fm.fileExists("Stubs/\(doc.id).json") { try? await fm.remove(relativePath: relativePath); return }
CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
} else if relativePath.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return }
if await fm.fileExists("Stubs/\(doc.id).json") { try? await fm.remove(relativePath: relativePath); return }
CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
}
}
/// Full sync against the current file set imports new/changed files and
/// prunes entities whose file is gone or tombstoned. Runs on connect so
/// changes accumulated while the app was closed are picked up.
private func reconcile() async {
guard let fm = fileManager else { return }
isSyncing = true
defer { isSyncing = false }
let tombstoned = Set(await fm.listTombstones().map(\.id))
let dataFiles = await fm.listDataFiles()
var liveSplitIDs = Set<String>()
var liveWorkoutIDs = Set<String>()
for path in dataFiles {
guard let data = try? await fm.read(relativePath: path) else { continue }
if path.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); continue }
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
liveSplitIDs.insert(doc.id)
} else if path.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); continue }
CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
liveWorkoutIDs.insert(doc.id)
}
}
// Prune cache entities no longer backed by a live file.
if let splits = try? context.fetch(FetchDescriptor<Split>()) {
for s in splits where !liveSplitIDs.contains(s.id) { context.delete(s) }
}
if let workouts = try? context.fetch(FetchDescriptor<Workout>()) {
for w in workouts where !liveWorkoutIDs.contains(w.id) { context.delete(w) }
}
try? context.save()
onCacheChanged?()
}
// MARK: - Cache deletes
private func deleteCachedEntity(id: String) {
if let s = CacheMapper.fetchSplit(id: id, in: context) { context.delete(s) }
if let w = CacheMapper.fetchWorkout(id: id, in: context) { context.delete(w) }
}
private func deleteCachedEntity(jsonRelativePath path: String) {
if let splits = try? context.fetch(FetchDescriptor<Split>(predicate: #Predicate { $0.jsonRelativePath == path })) {
splits.forEach(context.delete)
}
if let workouts = try? context.fetch(FetchDescriptor<Workout>(predicate: #Predicate { $0.jsonRelativePath == path })) {
workouts.forEach(context.delete)
}
}
private func idFromStubPath(_ path: String) -> String {
(path as NSString).lastPathComponent.replacingOccurrences(of: ".json", with: "")
}
// MARK: - Maintenance
private func cleanupOldStubs() {
guard let fm = fileManager else { return }
Task.detached(priority: .utility) {
let cutoff = Date().addingTimeInterval(-Tombstone.gracePeriod)
for tombstone in await fm.listTombstones() where tombstone.deletedAt < cutoff {
try? await fm.remove(relativePath: tombstone.relativePath)
}
}
}
}
-31
View File
@@ -1,31 +0,0 @@
import SwiftUI
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
}
}
func darker(by percentage: CGFloat = 0.2) -> Color {
return self.opacity(1.0 - percentage)
}
}
// Available colors for splits
let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
// Available system images for splits
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"]
-56
View File
@@ -1,56 +0,0 @@
import Foundation
extension Date {
func formattedDate() -> String {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: self)
}
func formattedTime() -> String {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter.string(from: self)
}
func isSameDay(as other: Date) -> Bool {
Calendar.current.isDate(self, inSameDayAs: other)
}
func formatDate() -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: self)
}
var abbreviatedMonth: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM"
return formatter.string(from: self)
}
var dayOfMonth: Int {
Calendar.current.component(.day, from: self)
}
var abbreviatedWeekday: String {
let formatter = DateFormatter()
formatter.dateFormat = "EEE"
return formatter.string(from: self)
}
func humanTimeInterval(to other: Date) -> String {
let interval = other.timeIntervalSince(self)
let hours = Int(interval) / 3600
let minutes = (Int(interval) % 3600) / 60
if hours > 0 {
return "\(hours)h \(minutes)m"
} else {
return "\(minutes)m"
}
}
}
@@ -8,32 +8,55 @@
//
import SwiftUI
import CoreData
import SwiftData
struct ExerciseAddEditView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@State private var showingExercisePicker = false
@ObservedObject var exercise: Exercise
// The exercise entity provides initial values (read-only).
let exercise: Exercise
// The parent split is needed to rebuild and save the SplitDocument.
let split: Split
@State private var originalWeight: Int32? = nil
@State private var loadType: LoadType = .none
// Local editable state
@State private var exerciseName: String
@State private var originalWeight: Int
@State private var loadType: LoadType
@State private var minutes: Int
@State private var seconds: Int
@State private var weightTens: Int
@State private var weightOnes: Int
@State private var reps: Int
@State private var sets: Int
@State private var weightReminderWeeks: Int
@State private var weightLastUpdated: Date?
@State private var minutes = 0
@State private var seconds = 0
init(exercise: Exercise, split: Split) {
self.exercise = exercise
self.split = split
@State private var weight_tens = 0
@State private var weight = 0
@State private var reps: Int = 0
@State private var sets: Int = 0
let w = exercise.weight
_exerciseName = State(initialValue: exercise.name)
_originalWeight = State(initialValue: w)
_loadType = State(initialValue: exercise.loadTypeEnum)
_minutes = State(initialValue: exercise.durationMinutes)
_seconds = State(initialValue: exercise.durationSeconds)
_weightTens = State(initialValue: (w / 10) * 10)
_weightOnes = State(initialValue: w % 10)
_reps = State(initialValue: exercise.reps)
_sets = State(initialValue: exercise.sets)
_weightReminderWeeks = State(initialValue: exercise.weightReminderTimeIntervalWeeks)
_weightLastUpdated = State(initialValue: exercise.weightLastUpdated)
}
var body: some View {
NavigationStack {
Form {
Section(header: Text("Exercise")) {
if exercise.name.isEmpty {
if exerciseName.isEmpty {
Button(action: {
showingExercisePicker = true
}) {
@@ -45,7 +68,7 @@ struct ExerciseAddEditView: View {
}
}
} else {
ListItem(title: exercise.name)
ListItem(title: exerciseName)
}
}
@@ -74,7 +97,10 @@ struct ExerciseAddEditView: View {
}
}
Section(header: Text("Load Type"), footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.")) {
Section(
header: Text("Load Type"),
footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.")
) {
Picker("", selection: $loadType) {
ForEach(LoadType.allCases, id: \.self) { load in
Text(load.name)
@@ -87,7 +113,7 @@ struct ExerciseAddEditView: View {
if loadType == .weight {
Section(header: Text("Weight")) {
HStack {
Picker("", selection: $weight_tens) {
Picker("", selection: $weightTens) {
ForEach(0..<100) { lbs in
Text("\(lbs * 10)").tag(lbs * 10)
}
@@ -95,7 +121,7 @@ struct ExerciseAddEditView: View {
.frame(height: 100)
.pickerStyle(.wheel)
Picker("", selection: $weight) {
Picker("", selection: $weightOnes) {
ForEach(0..<10) { lbs in
Text("\(lbs)").tag(lbs)
}
@@ -130,36 +156,21 @@ struct ExerciseAddEditView: View {
Section(header: Text("Weight Increase")) {
HStack {
Text("Remind every \(exercise.weightReminderTimeIntervalWeeks) weeks")
Text("Remind every \(weightReminderWeeks) weeks")
Spacer()
Stepper("", value: Binding(
get: { Int(exercise.weightReminderTimeIntervalWeeks) },
set: { exercise.weightReminderTimeIntervalWeeks = Int32($0) }
), in: 0...366)
Stepper("", value: $weightReminderWeeks, in: 0...366)
}
if let lastUpdated = exercise.weightLastUpdated {
if let lastUpdated = weightLastUpdated {
Text("Last weight change \(Date().humanTimeInterval(to: lastUpdated)) ago")
}
}
}
.onAppear {
originalWeight = exercise.weight
weight_tens = Int(exercise.weight) / 10 * 10
weight = Int(exercise.weight) % 10
loadType = exercise.loadTypeEnum
sets = Int(exercise.sets)
reps = Int(exercise.reps)
if let duration = exercise.duration {
minutes = Int(duration.timeIntervalSince1970) / 60
seconds = Int(duration.timeIntervalSince1970) % 60
}
}
.sheet(isPresented: $showingExercisePicker) {
ExercisePickerView { exerciseNames in
exercise.name = exerciseNames.first ?? "Unnamed"
exerciseName = exerciseNames.first ?? "Unnamed"
}
}
.navigationTitle(exercise.name.isEmpty ? "New Exercise" : exercise.name)
.navigationTitle(exerciseName.isEmpty ? "New Exercise" : exerciseName)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
@@ -169,19 +180,31 @@ struct ExerciseAddEditView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
if let originalWeight = originalWeight, originalWeight != Int32(weight_tens + weight) {
exercise.weightLastUpdated = Date()
}
exercise.duration = Date(timeIntervalSince1970: Double(minutes * 60 + seconds))
exercise.weight = Int32(weight_tens + weight)
exercise.sets = Int32(sets)
exercise.reps = Int32(reps)
exercise.loadType = Int32(loadType.rawValue)
try? viewContext.save()
saveExercise()
dismiss()
}
}
}
}
}
private func saveExercise() {
let newWeight = weightTens + weightOnes
let updatedWeightDate: Date? = newWeight != originalWeight ? Date() : weightLastUpdated
let durationSecs = minutes * 60 + seconds
var doc = SplitDocument(from: split)
if let idx = doc.exercises.firstIndex(where: { $0.id == exercise.id }) {
doc.exercises[idx].name = exerciseName
doc.exercises[idx].sets = sets
doc.exercises[idx].reps = reps
doc.exercises[idx].weight = newWeight
doc.exercises[idx].loadType = loadType.rawValue
doc.exercises[idx].durationSeconds = durationSecs
doc.exercises[idx].weightLastUpdated = updatedWeightDate
doc.exercises[idx].weightReminderWeeks = weightReminderWeeks
}
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
}
+137 -97
View File
@@ -8,156 +8,196 @@
//
import SwiftUI
import CoreData
import SwiftData
struct ExerciseListView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(\.dismiss) private var dismiss
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@ObservedObject var split: Split
var split: Split
@State private var showingAddSheet: Bool = false
@State private var itemToEdit: Exercise? = nil
@State private var itemToDelete: Exercise? = nil
@State private var createdWorkout: Workout? = nil
/// ID of the just-created workout; drives programmatic navigation once the
/// cache observer delivers the entity a beat after the file write.
@State private var pendingWorkoutID: String? = nil
@State private var resolvedWorkout: Workout? = nil
var body: some View {
NavigationStack {
Form {
let sortedExercises = split.exercisesArray
Form {
let sortedExercises = split.exercisesArray
if !sortedExercises.isEmpty {
ForEach(sortedExercises, id: \.objectID) { item in
ListItem(
title: item.name,
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
)
.swipeActions {
Button {
itemToDelete = item
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
Button {
itemToEdit = item
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
if !sortedExercises.isEmpty {
ForEach(sortedExercises) { item in
ListItem(
title: item.name,
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
)
.swipeActions {
Button {
itemToDelete = item
} label: {
Label("Delete", systemImage: "trash")
}
}
.onMove(perform: moveExercises)
Button {
showingAddSheet = true
} label: {
ListItem(title: "Add Exercise")
}
} else {
Text("No exercises added yet.")
Button(action: { showingAddSheet.toggle() }) {
ListItem(title: "Add Exercise")
.tint(.red)
Button {
itemToEdit = item
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
}
}
.onMove(perform: moveExercises)
Button {
showingAddSheet = true
} label: {
ListItem(title: "Add Exercise")
}
} else {
Text("No exercises added yet.")
Button(action: { showingAddSheet.toggle() }) {
ListItem(title: "Add Exercise")
}
}
.navigationTitle("\(split.name)")
}
.navigationTitle(split.name)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Start This Split") {
startWorkout()
}
.disabled(split.exercisesArray.isEmpty)
}
}
.navigationDestination(item: $createdWorkout) { workout in
// Navigate to the workout log once the entity appears in the cache.
.navigationDestination(item: $resolvedWorkout) { workout in
WorkoutLogListView(workout: workout)
}
// Poll for the entity after we write the document.
.onChange(of: pendingWorkoutID) { _, id in
guard let id else { return }
pollForWorkout(id: id)
}
.sheet(isPresented: $showingAddSheet) {
ExercisePickerView(onExerciseSelected: { exerciseNames in
addExercises(names: exerciseNames)
}, allowMultiSelect: true)
}
.sheet(item: $itemToEdit) { item in
ExerciseAddEditView(exercise: item)
ExerciseAddEditView(exercise: item, split: split)
}
.confirmationDialog(
"Delete Exercise?",
isPresented: .constant(itemToDelete != nil),
titleVisibility: .visible
) {
isPresented: Binding(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
),
titleVisibility: .visible,
presenting: itemToDelete
) { item in
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
viewContext.delete(item)
try? viewContext.save()
itemToDelete = nil
}
}
deleteExercise(item)
itemToDelete = nil
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
}
} message: { item in
Text("Remove \"\(item.name)\" from this split?")
}
}
// MARK: - Helpers
private func pollForWorkout(id: String) {
Task {
// Give the fileobservercache loop a moment to complete (typically < 1 s).
for _ in 0..<20 {
try? await Task.sleep(for: .milliseconds(150))
if let workout = CacheMapper.fetchWorkout(id: id, in: modelContext) {
resolvedWorkout = workout
pendingWorkoutID = nil
return
}
}
// If still not available after ~3 s, clear the pending ID silently.
pendingWorkoutID = nil
}
}
private func moveExercises(from source: IndexSet, to destination: Int) {
var exercises = split.exercisesArray
exercises.move(fromOffsets: source, toOffset: destination)
for (index, exercise) in exercises.enumerated() {
exercise.order = Int32(index)
var doc = SplitDocument(from: split)
doc.exercises = exercises.enumerated().map { i, ex in
var ed = ExerciseDocument(from: ex)
ed.order = i
return ed
}
try? viewContext.save()
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
private func startWorkout() {
let workout = Workout(context: viewContext)
workout.start = Date()
workout.end = Date()
workout.status = .notStarted
workout.split = split
for exercise in split.exercisesArray {
let workoutLog = WorkoutLog(context: viewContext)
workoutLog.exerciseName = exercise.name
workoutLog.date = Date()
workoutLog.order = exercise.order
workoutLog.sets = exercise.sets
workoutLog.reps = exercise.reps
workoutLog.weight = exercise.weight
workoutLog.status = .notStarted
workoutLog.workout = workout
let start = Date()
let logs = split.exercisesArray.enumerated().map { i, ex in
WorkoutLogDocument(
id: ULID.make(), exerciseName: ex.name, order: i,
sets: ex.sets, reps: ex.reps, weight: ex.weight,
loadType: ex.loadType, durationSeconds: ex.durationTotalSeconds,
currentStateIndex: 0, completed: false,
status: WorkoutStatus.notStarted.rawValue,
notes: nil, date: start
)
}
let doc = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchema,
id: ULID.make(),
splitID: split.id,
splitName: split.name,
start: start,
end: nil,
status: WorkoutStatus.notStarted.rawValue,
createdAt: start,
updatedAt: start,
logs: logs
)
Task {
await sync.save(workout: doc)
pendingWorkoutID = doc.id
}
try? viewContext.save()
createdWorkout = workout
}
private func addExercises(names: [String]) {
if names.count == 1 {
let exercise = Exercise(context: viewContext)
exercise.name = names.first ?? "Unnamed"
exercise.order = Int32(split.exercisesArray.count)
exercise.sets = 3
exercise.reps = 10
exercise.weight = 40
exercise.loadType = Int32(LoadType.weight.rawValue)
exercise.split = split
try? viewContext.save()
itemToEdit = exercise
} else {
let existingNames = Set(split.exercisesArray.map { $0.name })
for name in names where !existingNames.contains(name) {
let exercise = Exercise(context: viewContext)
exercise.name = name
exercise.order = Int32(split.exercisesArray.count)
exercise.sets = 3
exercise.reps = 10
exercise.weight = 40
exercise.loadType = Int32(LoadType.weight.rawValue)
exercise.split = split
var doc = SplitDocument(from: split)
let existingNames = Set(doc.exercises.map { $0.name })
let base = doc.exercises.count
let newDocs = names
.filter { !existingNames.contains($0) }
.enumerated()
.map { i, exName in
ExerciseDocument(
id: ULID.make(), name: exName, order: base + i,
sets: 3, reps: 10, weight: 40,
loadType: LoadType.weight.rawValue,
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
)
}
try? viewContext.save()
doc.exercises.append(contentsOf: newDocs)
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
private func deleteExercise(_ exercise: Exercise) {
var doc = SplitDocument(from: split)
doc.exercises.removeAll { $0.id == exercise.id }
for i in doc.exercises.indices {
doc.exercises[i].order = i
}
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
}
@@ -135,11 +135,7 @@ struct ExercisePickerView: View {
}
}
.onAppear {
loadExerciseLists()
exerciseLists = ExerciseListLoader.loadExerciseLists()
}
}
private func loadExerciseLists() {
exerciseLists = ExerciseListLoader.loadExerciseLists()
}
}
+33
View File
@@ -0,0 +1,33 @@
import SwiftUI
/// Gates the whole UI on iCloud availability. Files are the source of truth, so
/// there is no meaningful app without iCloud we never fall back to local-only.
struct RootGateView: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(\.scenePhase) private var scenePhase
var body: some View {
Group {
switch syncEngine.iCloudStatus {
case .checking:
ProgressView("Connecting to iCloud…")
case .available:
ContentView()
case .unavailable:
ContentUnavailableView {
Label("iCloud Required", systemImage: "icloud.slash")
} description: {
Text("Sign in to iCloud in Settings to use Workouts. Your data lives in iCloud Drive so it's safe and on all your devices.")
} actions: {
Button("Try Again") { Task { await syncEngine.connect() } }
.buttonStyle(.borderedProminent)
}
}
}
.onChange(of: scenePhase) { _, phase in
if phase == .active, syncEngine.iCloudStatus == .unavailable {
Task { await syncEngine.connect() }
}
}
}
}
+14 -21
View File
@@ -6,20 +6,14 @@
//
import SwiftUI
import CoreData
import SwiftData
import IndieAbout
struct SettingsView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Split.order, ascending: true),
NSSortDescriptor(keyPath: \Split.name, ascending: true)
],
animation: .default
)
private var splits: FetchedResults<Split>
@Query(sort: \Split.order) private var splits: [Split]
@State private var showingAddSplitSheet = false
@@ -47,7 +41,7 @@ struct SettingsView: View {
Spacer()
}
} else {
ForEach(splits, id: \.objectID) { split in
ForEach(splits) { split in
NavigationLink {
SplitDetailView(split: split)
} label: {
@@ -73,12 +67,16 @@ struct SettingsView: View {
Text("Add Split")
}
}
}
// MARK: - Account Section
Section(header: Text("Account")) {
Text("Settings coming soon")
.foregroundColor(.secondary)
Button {
Task { await SplitSeeder.seedDefaults(into: modelContext, using: sync) }
} label: {
HStack {
Image(systemName: "wand.and.sparkles")
.foregroundColor(.accentColor)
Text("Add Starter Splits")
}
}
}
// MARK: - About Section
@@ -99,8 +97,3 @@ struct SettingsView: View {
}
}
}
#Preview {
SettingsView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}
+2 -22
View File
@@ -6,25 +6,5 @@
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// Protocol for items that can be ordered in a sequence
protocol OrderableItem {
/// Updates the order of the item to the specified index
func updateOrder(to index: Int)
}
/// Extension to make Split conform to OrderableItem
extension Split: OrderableItem {
func updateOrder(to index: Int) {
self.order = Int32(index)
}
}
/// Extension to make Exercise conform to OrderableItem
extension Exercise: OrderableItem {
func updateOrder(to index: Int) {
self.order = Int32(index)
}
}
// No longer used reordering is now a SyncEngine document write (onMove doc save).
// File kept to avoid Xcode project reference errors.
+2 -89
View File
@@ -6,92 +6,5 @@
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import UniformTypeIdentifiers
public struct SortableForEach<Data, Content>: View where Data: Hashable, Content: View {
@Binding var data: [Data]
@Binding var allowReordering: Bool
private let content: (Data, Bool) -> Content
@State private var draggedItem: Data?
@State private var hasChangedLocation: Bool = false
public init(_ data: Binding<[Data]>,
allowReordering: Binding<Bool>,
@ViewBuilder content: @escaping (Data, Bool) -> Content) {
_data = data
_allowReordering = allowReordering
self.content = content
}
public var body: some View {
ForEach(data, id: \.self) { item in
if allowReordering {
content(item, hasChangedLocation && draggedItem == item)
.onDrag {
draggedItem = item
return NSItemProvider(object: "\(item.hashValue)" as NSString)
}
.onDrop(of: [UTType.plainText], delegate: DragRelocateDelegate(
item: item,
data: $data,
draggedItem: $draggedItem,
hasChangedLocation: $hasChangedLocation))
} else {
content(item, false)
}
}
}
struct DragRelocateDelegate<ItemType>: DropDelegate where ItemType: Equatable {
let item: ItemType
@Binding var data: [ItemType]
@Binding var draggedItem: ItemType?
@Binding var hasChangedLocation: Bool
func dropEntered(info: DropInfo) {
guard item != draggedItem,
let current = draggedItem,
let from = data.firstIndex(of: current),
let to = data.firstIndex(of: item)
else {
return
}
hasChangedLocation = true
if data[to] != current {
withAnimation {
data.move(
fromOffsets: IndexSet(integer: from),
toOffset: (to > from) ? to + 1 : to
)
}
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
// Update the order property of each item to match its position in the array
updateItemOrders()
hasChangedLocation = false
draggedItem = nil
return true
}
// Helper method to update the order property of each item
private func updateItemOrders() {
for (index, item) in data.enumerated() {
if let orderableItem = item as? any OrderableItem {
orderableItem.updateOrder(to: index)
}
}
}
}
}
// No longer used list reordering is handled with SwiftUI's native onMove modifier
// backed by SyncEngine document writes. File kept to avoid Xcode project reference errors.
+27 -17
View File
@@ -8,10 +8,11 @@
//
import SwiftUI
import CoreData
import SwiftData
struct SplitAddEditView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
let split: Split?
@@ -23,8 +24,6 @@ struct SplitAddEditView: View {
@State private var showingIconPicker: Bool = false
@State private var showingDeleteConfirmation: Bool = false
private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
var isEditing: Bool { split != nil }
init(split: Split?, onDelete: (() -> Void)? = nil) {
@@ -117,8 +116,9 @@ struct SplitAddEditView: View {
) {
Button("Delete", role: .destructive) {
if let split = split {
viewContext.delete(split)
try? viewContext.save()
Task {
await sync.delete(split: split)
}
dismiss()
onDelete?()
}
@@ -131,18 +131,28 @@ struct SplitAddEditView: View {
private func save() {
if let split = split {
// Update existing
split.name = name
split.color = color
split.systemImage = systemImage
// Update existing split
var doc = SplitDocument(from: split)
doc.name = name
doc.color = color
doc.systemImage = systemImage
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
} else {
// Create new
let newSplit = Split(context: viewContext)
newSplit.name = name
newSplit.color = color
newSplit.systemImage = systemImage
newSplit.order = 0
// Create new split
let existing = (try? modelContext.fetch(FetchDescriptor<Split>())) ?? []
let doc = SplitDocument(
schemaVersion: SplitDocument.currentSchema,
id: ULID.make(),
name: name,
color: color,
systemImage: systemImage,
order: existing.count,
createdAt: Date(),
updatedAt: Date(),
exercises: []
)
Task { await sync.save(split: doc) }
}
try? viewContext.save()
}
}
+88 -79
View File
@@ -8,13 +8,13 @@
//
import SwiftUI
import CoreData
import SwiftData
struct SplitDetailView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@ObservedObject var split: Split
var split: Split
@State private var showingExerciseAddSheet: Bool = false
@State private var showingSplitEditSheet: Bool = false
@@ -22,54 +22,52 @@ struct SplitDetailView: View {
@State private var itemToDelete: Exercise? = nil
var body: some View {
NavigationStack {
Form {
Section(header: Text("What is a Split?")) {
Text("A \"split\" is simply how you divide (or \"split up\") your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
.font(.caption)
}
Form {
Section(header: Text("What is a Split?")) {
Text("A \"split\" is simply how you divide (or \"split up\") your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
.font(.caption)
}
Section(header: Text("Exercises")) {
let sortedExercises = split.exercisesArray
Section(header: Text("Exercises")) {
let sortedExercises = split.exercisesArray
if !sortedExercises.isEmpty {
ForEach(sortedExercises, id: \.objectID) { item in
ListItem(
title: item.name,
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
)
.swipeActions {
Button {
itemToDelete = item
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
Button {
itemToEdit = item
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
if !sortedExercises.isEmpty {
ForEach(sortedExercises) { item in
ListItem(
title: item.name,
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
)
.swipeActions {
Button {
itemToDelete = item
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
Button {
itemToEdit = item
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
}
.onMove(perform: moveExercises)
}
.onMove(perform: moveExercises)
Button {
showingExerciseAddSheet = true
} label: {
ListItem(systemName: "plus.circle", title: "Add Exercise")
}
} else {
Text("No exercises added yet.")
Button(action: { showingExerciseAddSheet.toggle() }) {
ListItem(title: "Add Exercise")
}
Button {
showingExerciseAddSheet = true
} label: {
ListItem(systemName: "plus.circle", title: "Add Exercise")
}
} else {
Text("No exercises added yet.")
Button(action: { showingExerciseAddSheet.toggle() }) {
ListItem(title: "Add Exercise")
}
}
}
.navigationTitle("\(split.name)")
}
.navigationTitle(split.name)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
@@ -90,62 +88,73 @@ struct SplitDetailView: View {
}
}
.sheet(item: $itemToEdit) { item in
ExerciseAddEditView(exercise: item)
ExerciseAddEditView(exercise: item, split: split)
}
.confirmationDialog(
"Delete Exercise?",
isPresented: .constant(itemToDelete != nil),
titleVisibility: .visible
) {
isPresented: Binding(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
),
titleVisibility: .visible,
presenting: itemToDelete
) { item in
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
viewContext.delete(item)
try? viewContext.save()
itemToDelete = nil
}
}
deleteExercise(item)
itemToDelete = nil
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
}
} message: { item in
Text("Remove \"\(item.name)\" from this split?")
}
}
private func moveExercises(from source: IndexSet, to destination: Int) {
var exercises = split.exercisesArray
exercises.move(fromOffsets: source, toOffset: destination)
for (index, exercise) in exercises.enumerated() {
exercise.order = Int32(index)
var doc = SplitDocument(from: split)
doc.exercises = exercises.enumerated().map { i, ex in
var ed = ExerciseDocument(from: ex)
ed.order = i
return ed
}
try? viewContext.save()
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
private func addExercises(names: [String]) {
if names.count == 1 {
let exercise = Exercise(context: viewContext)
exercise.name = names.first ?? "Unnamed"
exercise.order = Int32(split.exercisesArray.count)
exercise.sets = 3
exercise.reps = 10
exercise.weight = 40
exercise.loadType = Int32(LoadType.weight.rawValue)
exercise.split = split
try? viewContext.save()
itemToEdit = exercise
} else {
let existingNames = Set(split.exercisesArray.map { $0.name })
for name in names where !existingNames.contains(name) {
let exercise = Exercise(context: viewContext)
exercise.name = name
exercise.order = Int32(split.exercisesArray.count)
exercise.sets = 3
exercise.reps = 10
exercise.weight = 40
exercise.loadType = Int32(LoadType.weight.rawValue)
exercise.split = split
var doc = SplitDocument(from: split)
let existingNames = Set(doc.exercises.map { $0.name })
let base = doc.exercises.count
let newDocs = names
.filter { !existingNames.contains($0) }
.enumerated()
.map { i, exName in
ExerciseDocument(
id: ULID.make(), name: exName, order: base + i,
sets: 3, reps: 10, weight: 40,
loadType: LoadType.weight.rawValue,
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
)
}
try? viewContext.save()
doc.exercises.append(contentsOf: newDocs)
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
// If a single exercise was added, open the edit sheet once the cache refreshes.
// We rely on the observer to populate it no direct entity reference needed.
}
private func deleteExercise(_ exercise: Exercise) {
var doc = SplitDocument(from: split)
doc.exercises.removeAll { $0.id == exercise.id }
// Re-number orders after removal
for i in doc.exercises.indices {
doc.exercises[i].order = i
}
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
import SwiftUI
struct SplitItem: View {
@ObservedObject var split: Split
var split: Split
var body: some View {
VStack {
+18 -38
View File
@@ -8,64 +8,44 @@
//
import SwiftUI
import CoreData
import SwiftData
struct SplitListView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Split.order, ascending: true),
NSSortDescriptor(keyPath: \Split.name, ascending: true)
],
animation: .default
)
private var fetchedSplits: FetchedResults<Split>
@State private var splits: [Split] = []
@State private var allowSorting: Bool = true
@Query(sort: \Split.order) private var splits: [Split]
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
ForEach(splits) { split in
NavigationLink {
SplitDetailView(split: split)
} label: {
SplitItem(split: split)
.overlay(dragging ? Color.white.opacity(0.8) : Color.clear)
}
}
}
.padding()
}
.overlay {
if fetchedSplits.isEmpty {
if splits.isEmpty {
ContentUnavailableView(
"No Splits Yet",
systemImage: "dumbbell.fill",
description: Text("Create a split to organize your workout routine.")
label: {
Label("No Splits Yet", systemImage: "dumbbell.fill")
},
description: {
Text("Create a split to organize your workout routine.")
},
actions: {
Button("Add Starter Splits") {
Task { await SplitSeeder.seedDefaults(into: modelContext, using: sync) }
}
.buttonStyle(.borderedProminent)
}
)
}
}
.onAppear {
splits = Array(fetchedSplits)
}
.onChange(of: fetchedSplits.count) { _, _ in
splits = Array(fetchedSplits)
}
.onChange(of: splits) { _, _ in
saveContext()
}
}
private func saveContext() {
if viewContext.hasChanges {
do {
try viewContext.save()
} catch {
print("Error saving after reorder: \(error)")
}
}
}
}
-8
View File
@@ -8,11 +8,8 @@
//
import SwiftUI
import CoreData
struct SplitsView: View {
@Environment(\.managedObjectContext) private var viewContext
@State private var showingAddSheet: Bool = false
var body: some View {
@@ -32,8 +29,3 @@ struct SplitsView: View {
}
}
}
#Preview {
SplitsView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}
+118 -62
View File
@@ -8,15 +8,20 @@
//
import SwiftUI
import CoreData
import SwiftData
import Charts
struct ExerciseView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@ObservedObject var workoutLog: WorkoutLog
let workout: Workout
let logID: String
/// Working copy of the parent workout. Editing a log = editing this doc and
/// re-saving the whole aggregate. Driving the UI from local state (not the
/// cache entity) keeps rapid set taps from racing the filecache update.
@State private var doc: WorkoutDocument
@State private var progress: Int = 0
@State private var showingPlanEdit = false
@State private var showingNotesEdit = false
@@ -24,12 +29,49 @@ struct ExerciseView: View {
let notStartedColor = Color.white
let completedColor = Color.green
/// `seedDoc` lets the caller hand over an in-memory document (e.g. the parent's
/// working copy right after adding an exercise) so the screen doesn't wait on
/// the filecache round-trip to find the just-created log.
init(workout: Workout, logID: String, seedDoc: WorkoutDocument? = nil) {
self.workout = workout
self.logID = logID
_doc = State(initialValue: seedDoc ?? WorkoutDocument(from: workout))
}
/// The log being edited within the working doc.
private var log: WorkoutLogDocument? {
doc.logs.first { $0.id == logID }
}
var body: some View {
Group {
if let log {
content(for: log)
} else {
// The just-added log hasn't reached the cache yet; refresh shortly.
ProgressView()
}
}
.navigationTitle(log?.exerciseName ?? "")
.sheet(isPresented: $showingPlanEdit) {
PlanEditView(workout: workout, logID: logID)
}
.sheet(isPresented: $showingNotesEdit) {
NotesEditView(workout: workout, logID: logID)
}
.onAppear {
refreshDocIfNeeded()
progress = log?.currentStateIndex ?? 0
}
}
@ViewBuilder
private func content(for log: WorkoutLogDocument) -> some View {
Form {
// MARK: - Progress Section
Section(header: Text("Progress")) {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: Int(workoutLog.sets)), spacing: 4) {
ForEach(1...max(1, Int(workoutLog.sets)), id: \.self) { index in
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: max(1, log.sets)), spacing: 4) {
ForEach(1...max(1, log.sets), id: \.self) { index in
ZStack {
let completed = index <= progress
let color = completed ? completedColor : notStartedColor
@@ -50,21 +92,17 @@ struct ExerciseView: View {
.colorInvert()
}
.onTapGesture {
let totalSets = Int(workoutLog.sets)
let totalSets = log.sets
let isLastTile = index == totalSets
let wasAlreadyAtThisProgress = progress == index
withAnimation(.easeInOut(duration: 0.2)) {
if wasAlreadyAtThisProgress {
progress = 0
} else {
progress = index
}
progress = wasAlreadyAtThisProgress ? 0 : index
}
updateLogStatus()
// If tapping the last tile to complete, go back to list
// Tapping the final tile to complete returns to the list.
if isLastTile && !wasAlreadyAtThisProgress {
dismiss()
}
@@ -75,7 +113,7 @@ struct ExerciseView: View {
// MARK: - Plan Section (Read-only with Edit button)
Section {
PlanTilesView(workoutLog: workoutLog)
PlanTilesView(log: log)
} header: {
HStack {
Text("Plan")
@@ -90,7 +128,7 @@ struct ExerciseView: View {
// MARK: - Notes Section (Read-only with Edit button)
Section {
if let notes = workoutLog.notes, !notes.isEmpty {
if let notes = log.notes, !notes.isEmpty {
Text(notes)
.foregroundColor(.primary)
} else {
@@ -112,93 +150,111 @@ struct ExerciseView: View {
// MARK: - Progress Tracking Chart
Section(header: Text("Progress Tracking")) {
WeightProgressionChartView(exerciseName: workoutLog.exerciseName)
WeightProgressionChartView(exerciseName: log.exerciseName)
}
}
.navigationTitle(workoutLog.exerciseName)
.sheet(isPresented: $showingPlanEdit) {
PlanEditView(workoutLog: workoutLog)
// Pull plan/notes edits made in the sheets back into the live doc.
.onChange(of: showingPlanEdit) { _, presenting in
if !presenting { refreshDocFromCache() }
}
.sheet(isPresented: $showingNotesEdit) {
NotesEditView(workoutLog: workoutLog)
}
.onAppear {
progress = Int(workoutLog.currentStateIndex)
}
.onChange(of: workoutLog.currentStateIndex) { _, newValue in
// Update local state when CoreData changes (e.g., from Watch sync)
if progress != Int(newValue) {
withAnimation(.easeInOut(duration: 0.2)) {
progress = Int(newValue)
}
}
.onChange(of: showingNotesEdit) { _, presenting in
if !presenting { refreshDocFromCache() }
}
}
// MARK: - Mutations
private func updateLogStatus() {
workoutLog.currentStateIndex = Int32(progress)
if progress >= Int(workoutLog.sets) {
workoutLog.status = .completed
workoutLog.completed = true
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].currentStateIndex = progress
if progress >= doc.logs[i].sets {
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
} else if progress > 0 {
workoutLog.status = .inProgress
workoutLog.completed = false
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
} else {
workoutLog.status = .notStarted
workoutLog.completed = false
doc.logs[i].status = WorkoutStatus.notStarted.rawValue
doc.logs[i].completed = false
}
updateWorkoutStatus()
saveChanges()
recomputeWorkoutStatus()
doc.updatedAt = Date()
let snapshot = doc
Task { await sync.save(workout: snapshot) }
}
private func updateWorkoutStatus() {
guard let workout = workoutLog.workout else { return }
let logs = workout.logsArray
let allCompleted = logs.allSatisfy { $0.status == .completed }
let anyInProgress = logs.contains { $0.status == .inProgress }
let allNotStarted = logs.allSatisfy { $0.status == .notStarted }
/// Recompute the workout's status/end from its logs.
private func recomputeWorkoutStatus() {
let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
let anyInProgress = statuses.contains { $0 == .inProgress }
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
if allCompleted {
workout.status = .completed
workout.end = Date()
doc.status = WorkoutStatus.completed.rawValue
doc.end = Date()
} else if anyInProgress || !allNotStarted {
workout.status = .inProgress
doc.status = WorkoutStatus.inProgress.rawValue
doc.end = nil
} else {
workout.status = .notStarted
doc.status = WorkoutStatus.notStarted.rawValue
doc.end = nil
}
}
private func saveChanges() {
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
/// If the requested log isn't in the working doc yet (just-added race), pull a
/// fresh copy from the cache entity once it catches up.
private func refreshDocIfNeeded() {
guard log == nil else { return }
refreshDocFromCache()
}
/// Re-read the workout from the cache to absorb edits made by child sheets
/// (plan/notes) without clobbering progress edits made here.
private func refreshDocFromCache() {
let fresh = WorkoutDocument(from: workout)
// Preserve the locally edited progress for the open log if the cache lags.
if let i = fresh.logs.firstIndex(where: { $0.id == logID }),
let mine = doc.logs.first(where: { $0.id == logID }),
fresh.logs[i].currentStateIndex != mine.currentStateIndex {
doc = fresh
doc.logs[i].currentStateIndex = mine.currentStateIndex
} else {
doc = fresh
}
if let current = log {
progress = current.currentStateIndex
}
}
}
// MARK: - Plan Tiles View
struct PlanTilesView: View {
@ObservedObject var workoutLog: WorkoutLog
let log: WorkoutLogDocument
var body: some View {
if workoutLog.loadTypeEnum == .duration {
if LoadType(rawValue: log.loadType) == .duration {
// Duration layout: Sets | Duration
HStack(spacing: 0) {
PlanTile(label: "Sets", value: "\(workoutLog.sets)")
PlanTile(label: "Sets", value: "\(log.sets)")
PlanTile(label: "Duration", value: formattedDuration)
}
} else {
// Weight layout: Sets | Reps | Weight
HStack(spacing: 0) {
PlanTile(label: "Sets", value: "\(workoutLog.sets)")
PlanTile(label: "Reps", value: "\(workoutLog.reps)")
PlanTile(label: "Weight", value: "\(workoutLog.weight) lbs")
PlanTile(label: "Sets", value: "\(log.sets)")
PlanTile(label: "Reps", value: "\(log.reps)")
PlanTile(label: "Weight", value: "\(log.weight) lbs")
}
}
}
private var formattedDuration: String {
let mins = workoutLog.durationMinutes
let secs = workoutLog.durationSeconds
let mins = log.durationSeconds / 60
let secs = log.durationSeconds % 60
if mins > 0 && secs > 0 {
return "\(mins)m \(secs)s"
} else if mins > 0 {
+12 -7
View File
@@ -6,13 +6,14 @@
//
import SwiftUI
import CoreData
import SwiftData
struct NotesEditView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@ObservedObject var workoutLog: WorkoutLog
let workout: Workout
let logID: String
@State private var notesText: String = ""
@@ -40,14 +41,18 @@ struct NotesEditView: View {
}
}
.onAppear {
notesText = workoutLog.notes ?? ""
notesText = WorkoutDocument(from: workout)
.logs.first(where: { $0.id == logID })?.notes ?? ""
}
}
}
private func saveChanges() {
workoutLog.notes = notesText
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
var doc = WorkoutDocument(from: workout)
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].notes = notesText
doc.updatedAt = Date()
let snapshot = doc
Task { await sync.save(workout: snapshot) }
}
}
+46 -29
View File
@@ -6,13 +6,15 @@
//
import SwiftUI
import CoreData
import SwiftData
struct PlanEditView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@ObservedObject var workoutLog: WorkoutLog
let workout: Workout
let logID: String
@State private var sets: Int = 3
@State private var reps: Int = 12
@@ -21,11 +23,6 @@ struct PlanEditView: View {
@State private var durationSeconds: Int = 0
@State private var selectedLoadType: LoadType = .weight
// Find the corresponding exercise in the split for syncing changes
private var correspondingExercise: Exercise? {
workoutLog.workout?.split?.exercisesArray.first { $0.name == workoutLog.exerciseName }
}
var body: some View {
NavigationStack {
Form {
@@ -135,34 +132,54 @@ struct PlanEditView: View {
}
}
.onAppear {
sets = Int(workoutLog.sets)
reps = Int(workoutLog.reps)
weight = Int(workoutLog.weight)
durationMinutes = workoutLog.durationMinutes
durationSeconds = workoutLog.durationSeconds
selectedLoadType = workoutLog.loadTypeEnum
if let log = WorkoutDocument(from: workout).logs.first(where: { $0.id == logID }) {
sets = log.sets
reps = log.reps
weight = log.weight
durationMinutes = log.durationSeconds / 60
durationSeconds = log.durationSeconds % 60
selectedLoadType = LoadType(rawValue: log.loadType) ?? .weight
}
}
}
}
private func saveChanges() {
workoutLog.sets = Int32(sets)
workoutLog.reps = Int32(reps)
workoutLog.weight = Int32(weight)
workoutLog.durationMinutes = durationMinutes
workoutLog.durationSeconds = durationSeconds
workoutLog.loadTypeEnum = selectedLoadType
let totalSeconds = durationMinutes * 60 + durationSeconds
// Sync to corresponding exercise
if let exercise = correspondingExercise {
exercise.sets = workoutLog.sets
exercise.reps = workoutLog.reps
exercise.weight = workoutLog.weight
exercise.loadType = workoutLog.loadType
exercise.duration = workoutLog.duration
// 1) Update the log within the parent workout document.
var doc = WorkoutDocument(from: workout)
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].sets = sets
doc.logs[i].reps = reps
doc.logs[i].weight = weight
doc.logs[i].durationSeconds = totalSeconds
doc.logs[i].loadType = selectedLoadType.rawValue
doc.updatedAt = Date()
let exerciseName = doc.logs[i].exerciseName
let workoutDoc = doc
// 2) Mirror the plan onto the matching exercise in the split template.
var splitDoc: SplitDocument?
if let splitID = doc.splitID,
let split = CacheMapper.fetchSplit(id: splitID, in: modelContext) {
var sDoc = SplitDocument(from: split)
if let ei = sDoc.exercises.firstIndex(where: { $0.name == exerciseName }) {
sDoc.exercises[ei].sets = sets
sDoc.exercises[ei].reps = reps
sDoc.exercises[ei].weight = weight
sDoc.exercises[ei].durationSeconds = totalSeconds
sDoc.exercises[ei].loadType = selectedLoadType.rawValue
sDoc.updatedAt = Date()
splitDoc = sDoc
}
}
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
Task {
await sync.save(workout: workoutDoc)
if let splitDoc {
await sync.save(split: splitDoc)
}
}
}
}
@@ -9,21 +9,31 @@
import SwiftUI
import Charts
import CoreData
import SwiftData
struct WeightProgressionChartView: View {
@Environment(\.managedObjectContext) private var viewContext
let exerciseName: String
@State private var weightData: [WeightDataPoint] = []
@State private var isLoading: Bool = true
@State private var motivationalMessage: String = ""
/// Completed logs for this exercise, oldest first.
@Query private var logs: [WorkoutLog]
init(exerciseName: String) {
self.exerciseName = exerciseName
let name = exerciseName
_logs = Query(
filter: #Predicate<WorkoutLog> { $0.exerciseName == name && $0.completed },
sort: \WorkoutLog.date,
order: .forward
)
}
private var weightData: [WeightDataPoint] {
logs.map { WeightDataPoint(date: $0.date, weight: $0.weight) }
}
var body: some View {
VStack(alignment: .leading) {
if isLoading {
ProgressView("Loading data...")
} else if weightData.isEmpty {
if weightData.isEmpty {
Text("No weight history available yet.")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
@@ -70,51 +80,33 @@ struct WeightProgressionChartView: View {
}
}
.padding()
.onAppear {
loadWeightData()
}
}
private func loadWeightData() {
isLoading = true
let request: NSFetchRequest<WorkoutLog> = WorkoutLog.fetchRequest()
request.predicate = NSPredicate(format: "exerciseName == %@ AND completed == YES", exerciseName)
request.sortDescriptors = [NSSortDescriptor(keyPath: \WorkoutLog.date, ascending: true)]
if let logs = try? viewContext.fetch(request) {
weightData = logs.map { log in
WeightDataPoint(date: log.date, weight: Int(log.weight))
}
generateMotivationalMessage()
private var motivationalMessage: String {
let data = weightData
guard data.count >= 2 else {
return "Complete more workouts to track your progress!"
}
isLoading = false
}
private func generateMotivationalMessage() {
guard weightData.count >= 2 else {
motivationalMessage = "Complete more workouts to track your progress!"
return
}
let firstWeight = weightData.first?.weight ?? 0
let currentWeight = weightData.last?.weight ?? 0
let firstWeight = data.first?.weight ?? 0
let currentWeight = data.last?.weight ?? 0
let weightDifference = currentWeight - firstWeight
if weightDifference > 0 {
let percentIncrease = Int((Double(weightDifference) / Double(firstWeight)) * 100)
let percentIncrease = firstWeight > 0
? Int((Double(weightDifference) / Double(firstWeight)) * 100)
: 0
if percentIncrease >= 20 {
motivationalMessage = "Amazing progress! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
return "Amazing progress! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
} else if percentIncrease >= 10 {
motivationalMessage = "Great job! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
return "Great job! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
} else {
motivationalMessage = "You're making progress! Weight increased by \(weightDifference) lbs. Keep it up!"
return "You're making progress! Weight increased by \(weightDifference) lbs. Keep it up!"
}
} else if weightDifference == 0 {
motivationalMessage = "You're maintaining consistent weight. Focus on form and consider increasing when ready!"
return "You're maintaining consistent weight. Focus on form and consider increasing when ready!"
} else {
motivationalMessage = "Your current weight is lower than when you started. Adjust your training as needed and keep pushing!"
return "Your current weight is lower than when you started. Adjust your training as needed and keep pushing!"
}
}
}
@@ -8,24 +8,47 @@
//
import SwiftUI
import CoreData
import SwiftData
struct WorkoutLogListView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@ObservedObject var workout: Workout
let workout: Workout
/// Working copy of the workout aggregate. Active-session edits mutate this
/// local value (not the cache entity), which avoids losing rapid taps to the
/// fileobservercache lag, and is the single source of truth while the
/// screen is open.
@State private var doc: WorkoutDocument
@State private var showingAddSheet = false
@State private var itemToDelete: WorkoutLog? = nil
@State private var newlyAddedLog: WorkoutLog? = nil
@State private var logToDelete: WorkoutLogDocument?
@State private var addedLog: AddedLogRoute?
var sortedWorkoutLogs: [WorkoutLog] {
workout.logsArray
/// Wrapper so the programmatic push after adding an exercise uses a distinct
/// `navigationDestination(item:)` and doesn't collide with the value-based
/// row links registered for `String`.
private struct AddedLogRoute: Identifiable, Hashable { let id: String }
init(workout: Workout) {
self.workout = workout
_doc = State(initialValue: WorkoutDocument(from: workout))
}
private var sortedLogs: [WorkoutLogDocument] {
doc.logs.sorted { $0.order < $1.order }
}
/// The split this workout was started from (for adding more exercises).
private var split: Split? {
guard let splitID = doc.splitID else { return nil }
return CacheMapper.fetchSplit(id: splitID, in: modelContext)
}
var body: some View {
Group {
if sortedWorkoutLogs.isEmpty {
if sortedLogs.isEmpty {
ContentUnavailableView {
Label("No Exercises", systemImage: "figure.strengthtraining.traditional")
} description: {
@@ -40,15 +63,11 @@ struct WorkoutLogListView: View {
}
} else {
Form {
Section(header: Text("\(workout.label)")) {
ForEach(sortedWorkoutLogs, id: \.objectID) { log in
let workoutLogStatus = log.status.checkboxStatus
NavigationLink {
ExerciseView(workoutLog: log)
} label: {
Section(header: Text(label)) {
ForEach(sortedLogs) { log in
NavigationLink(value: log.id) {
CheckboxListItem(
status: workoutLogStatus,
status: workoutStatus(log).checkboxStatus,
title: log.exerciseName,
subtitle: subtitleForLog(log)
) {
@@ -65,7 +84,7 @@ struct WorkoutLogListView: View {
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button {
itemToDelete = log
logToDelete = log
} label: {
Label("Delete", systemImage: "trash")
}
@@ -77,130 +96,173 @@ struct WorkoutLogListView: View {
}
}
}
.navigationDestination(item: $newlyAddedLog) { log in
ExerciseView(workoutLog: log)
.navigationDestination(for: String.self) { logID in
ExerciseView(workout: workout, logID: logID)
}
.navigationDestination(item: $addedLog) { route in
// Seed with our working doc so the brand-new log is available before
// the cache catches up.
ExerciseView(workout: workout, logID: route.id, seedDoc: doc)
}
.navigationTitle(doc.splitName ?? Split.unnamed)
// Absorb edits made in pushed children (ExerciseView/Plan/Notes) once the
// cache reflects them, so the list shows live status on return.
.onChange(of: workout.updatedAt) { _, _ in
doc = WorkoutDocument(from: workout)
}
.navigationTitle("\(workout.split?.name ?? Split.unnamed)")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingAddSheet.toggle() }) {
Button {
showingAddSheet.toggle()
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddSheet) {
SplitExercisePickerSheet(
split: workout.split,
existingExerciseNames: Set(sortedWorkoutLogs.map { $0.exerciseName })
split: split,
existingExerciseNames: Set(sortedLogs.map { $0.exerciseName })
) { exercise in
addExerciseFromSplit(exercise)
}
}
.confirmationDialog(
"Delete Exercise?",
isPresented: Binding<Bool>(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
isPresented: Binding(
get: { logToDelete != nil },
set: { if !$0 { logToDelete = nil } }
),
titleVisibility: .visible
) {
titleVisibility: .visible,
presenting: logToDelete
) { log in
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
viewContext.delete(item)
updateWorkoutStatus()
try? viewContext.save()
itemToDelete = nil
}
}
deleteLog(log)
logToDelete = nil
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
logToDelete = nil
}
}
}
private func cycleStatus(for log: WorkoutLog) {
switch log.status {
case .notStarted:
log.status = .inProgress
case .inProgress:
log.status = .completed
case .completed:
log.status = .notStarted
case .skipped:
log.status = .notStarted
}
updateWorkoutStatus()
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
}
// MARK: - Derived
private func completeLog(_ log: WorkoutLog) {
log.status = .completed
updateWorkoutStatus()
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
}
private func updateWorkoutStatus() {
let logs = sortedWorkoutLogs
let allCompleted = logs.allSatisfy { $0.status == .completed }
let anyInProgress = logs.contains { $0.status == .inProgress }
let allNotStarted = logs.allSatisfy { $0.status == .notStarted }
if allCompleted {
workout.status = .completed
workout.end = Date()
} else if anyInProgress || !allNotStarted {
workout.status = .inProgress
private var label: String {
if doc.status == WorkoutStatus.completed.rawValue, let end = doc.end {
if doc.start.isSameDay(as: end) {
return "\(doc.start.formattedDate())\(end.formattedTime())"
} else {
return "\(doc.start.formattedDate())\(end.formattedDate())"
}
} else {
workout.status = .notStarted
return doc.start.formattedDate()
}
}
private func workoutStatus(_ log: WorkoutLogDocument) -> WorkoutStatus {
WorkoutStatus(rawValue: log.status) ?? .notStarted
}
// MARK: - Mutations (drive the local doc, persist via SyncEngine)
private func cycleStatus(for log: WorkoutLogDocument) {
guard let i = doc.logs.firstIndex(where: { $0.id == log.id }) else { return }
let next: WorkoutStatus
switch workoutStatus(log) {
case .notStarted: next = .inProgress
case .inProgress: next = .completed
case .completed: next = .notStarted
case .skipped: next = .notStarted
}
doc.logs[i].status = next.rawValue
doc.logs[i].completed = (next == .completed)
save()
}
private func completeLog(_ log: WorkoutLogDocument) {
guard let i = doc.logs.firstIndex(where: { $0.id == log.id }) else { return }
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
save()
}
private func deleteLog(_ log: WorkoutLogDocument) {
withAnimation {
doc.logs.removeAll { $0.id == log.id }
save()
}
}
private func moveLog(from source: IndexSet, to destination: Int) {
var logs = sortedWorkoutLogs
var logs = sortedLogs
logs.move(fromOffsets: source, toOffset: destination)
for (index, log) in logs.enumerated() {
log.order = Int32(index)
if let i = doc.logs.firstIndex(where: { $0.id == log.id }) {
doc.logs[i].order = index
}
}
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
save()
}
private func addExerciseFromSplit(_ exercise: Exercise) {
let now = Date()
// Update workout start time if this is the first exercise
if sortedWorkoutLogs.isEmpty {
workout.start = now
// Reuse the workout's start time only when it's the very first exercise.
if doc.logs.isEmpty {
doc.start = now
}
workout.end = nil
doc.end = nil
let log = WorkoutLog(context: viewContext)
log.exerciseName = exercise.name
log.date = now
log.order = Int32(sortedWorkoutLogs.count)
log.sets = exercise.sets
log.reps = exercise.reps
log.weight = exercise.weight
log.loadType = exercise.loadType
log.duration = exercise.duration
log.status = .notStarted
log.workout = workout
let newLog = WorkoutLogDocument(
id: ULID.make(),
exerciseName: exercise.name,
order: doc.logs.count,
sets: exercise.sets,
reps: exercise.reps,
weight: exercise.weight,
loadType: exercise.loadType,
durationSeconds: exercise.durationTotalSeconds,
currentStateIndex: 0,
completed: false,
status: WorkoutStatus.notStarted.rawValue,
notes: nil,
date: now
)
doc.logs.append(newLog)
save()
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
// Navigate to the new exercise view
newlyAddedLog = log
// Push the new exercise straight away.
addedLog = AddedLogRoute(id: newLog.id)
}
private func subtitleForLog(_ log: WorkoutLog) -> String {
if log.loadTypeEnum == .duration {
let mins = log.durationMinutes
let secs = log.durationSeconds
/// Recompute the workout's status/end from its logs, then persist.
private func save() {
let statuses = doc.logs.map { workoutStatus($0) }
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
let anyInProgress = statuses.contains { $0 == .inProgress }
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
if allCompleted {
doc.status = WorkoutStatus.completed.rawValue
doc.end = Date()
} else if anyInProgress || !allNotStarted {
doc.status = WorkoutStatus.inProgress.rawValue
doc.end = nil
} else {
doc.status = WorkoutStatus.notStarted.rawValue
doc.end = nil
}
doc.updatedAt = Date()
let snapshot = doc
Task { await sync.save(workout: snapshot) }
}
private func subtitleForLog(_ log: WorkoutLogDocument) -> String {
if LoadType(rawValue: log.loadType) == .duration {
let mins = log.durationSeconds / 60
let secs = log.durationSeconds % 60
if mins > 0 && secs > 0 {
return "\(log.sets) × \(mins)m \(secs)s"
} else if mins > 0 {
@@ -224,7 +286,7 @@ struct SplitExercisePickerSheet: View {
let onExerciseSelected: (Exercise) -> Void
private var availableExercises: [Exercise] {
guard let split = split else { return [] }
guard let split else { return [] }
return split.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
}
@@ -233,7 +295,7 @@ struct SplitExercisePickerSheet: View {
Group {
if !availableExercises.isEmpty {
List {
ForEach(availableExercises, id: \.objectID) { exercise in
ForEach(availableExercises) { exercise in
Button {
onExerciseSelected(exercise)
dismiss()
@@ -8,29 +8,29 @@
//
import SwiftUI
import CoreData
import SwiftData
struct WorkoutLogsView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Workout.start, ascending: false)],
animation: .default
)
private var workouts: FetchedResults<Workout>
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
@State private var showingSplitPicker = false
@State private var showingSettings = false
@State private var itemToDelete: Workout? = nil
@State private var itemToDelete: Workout?
// WorkoutLogsView is the app's root screen, so it owns its NavigationStack.
var body: some View {
NavigationStack {
List {
ForEach(workouts, id: \.objectID) { workout in
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
ForEach(workouts) { workout in
NavigationLink {
WorkoutLogListView(workout: workout)
} label: {
CalendarListItem(
date: workout.start,
title: workout.split?.name ?? Split.unnamed,
title: workout.splitName ?? Split.unnamed,
subtitle: getSubtitle(for: workout),
subtitle2: workout.statusName
)
@@ -77,21 +77,16 @@ struct WorkoutLogsView: View {
}
.confirmationDialog(
"Delete Workout?",
isPresented: Binding<Bool>(
isPresented: Binding(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
),
titleVisibility: .visible
) {
titleVisibility: .visible,
presenting: itemToDelete
) { workout in
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
viewContext.delete(item)
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
itemToDelete = nil
}
}
Task { await sync.delete(workout: workout) }
itemToDelete = nil
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
@@ -112,22 +107,16 @@ struct WorkoutLogsView: View {
// MARK: - Split Picker Sheet
struct SplitPickerSheet: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Split.order, ascending: true),
NSSortDescriptor(keyPath: \Split.name, ascending: true)
],
animation: .default
)
private var splits: FetchedResults<Split>
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
private var splits: [Split]
var body: some View {
NavigationStack {
List {
ForEach(splits, id: \.objectID) { split in
ForEach(splits) { split in
Button {
startWorkout(with: split)
} label: {
@@ -155,35 +144,40 @@ struct SplitPickerSheet: View {
}
private func startWorkout(with split: Split) {
let workout = Workout(context: viewContext)
workout.start = Date()
workout.status = .notStarted
workout.split = split
for exercise in split.exercisesArray {
let workoutLog = WorkoutLog(context: viewContext)
workoutLog.exerciseName = exercise.name
workoutLog.date = Date()
workoutLog.order = exercise.order
workoutLog.sets = exercise.sets
workoutLog.reps = exercise.reps
workoutLog.weight = exercise.weight
workoutLog.loadType = exercise.loadType
workoutLog.duration = exercise.duration
workoutLog.status = .notStarted
workoutLog.workout = workout
let start = Date()
let logs = split.exercisesArray.enumerated().map { index, exercise in
WorkoutLogDocument(
id: ULID.make(),
exerciseName: exercise.name,
order: index,
sets: exercise.sets,
reps: exercise.reps,
weight: exercise.weight,
loadType: exercise.loadType,
durationSeconds: exercise.durationTotalSeconds,
currentStateIndex: 0,
completed: false,
status: WorkoutStatus.notStarted.rawValue,
notes: nil,
date: start
)
}
try? viewContext.save()
// Sync to Watch
WatchConnectivityManager.shared.syncAllData()
// A freshly started workout has no `end` only completion stamps it.
let doc = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchema,
id: ULID.make(),
splitID: split.id,
splitName: split.name,
start: start,
end: nil,
status: WorkoutStatus.notStarted.rawValue,
createdAt: start,
updatedAt: start,
logs: logs
)
Task { await sync.save(workout: doc) }
dismiss()
}
}
#Preview {
WorkoutLogsView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>Workouts.xcdatamodel</string>
</dict>
</plist>
@@ -1,46 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23231" systemVersion="24D5034f" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
<entity name="Split" representedClassName="Split" syncable="YES">
<attribute name="color" attributeType="String" defaultValueString="indigo"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="systemImage" attributeType="String" defaultValueString="dumbbell.fill"/>
<relationship name="exercises" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Exercise" inverseName="split" inverseEntity="Exercise"/>
<relationship name="workouts" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Workout" inverseName="split" inverseEntity="Workout"/>
</entity>
<entity name="Exercise" representedClassName="Exercise" syncable="YES">
<attribute name="duration" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<attribute name="loadType" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="weight" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="weightLastUpdated" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<attribute name="weightReminderTimeIntervalWeeks" attributeType="Integer 32" defaultValueString="2" usesScalarValueType="YES"/>
<relationship name="split" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Split" inverseName="exercises" inverseEntity="Split"/>
</entity>
<entity name="Workout" representedClassName="Workout" syncable="YES">
<attribute name="end" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="start" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<attribute name="status" attributeType="String" defaultValueString="notStarted"/>
<relationship name="logs" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="WorkoutLog" inverseName="workout" inverseEntity="WorkoutLog"/>
<relationship name="split" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Split" inverseName="workouts" inverseEntity="Split"/>
</entity>
<entity name="WorkoutLog" representedClassName="WorkoutLog" syncable="YES">
<attribute name="completed" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="currentStateIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="date" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<attribute name="duration" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<attribute name="elapsedSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="exerciseName" attributeType="String" defaultValueString=""/>
<attribute name="loadType" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="notes" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="status" optional="YES" attributeType="String" defaultValueString="notStarted"/>
<attribute name="weight" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="workout" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Workout" inverseName="logs" inverseEntity="Workout"/>
</entity>
</model>
+10 -17
View File
@@ -1,31 +1,24 @@
//
// WorkoutsApp.swift
// Workouts
// WorkoutsApp.swift
// Workouts
//
// Created by rzen on 8/13/25 at 11:10 AM.
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import CoreData
import SwiftData
@main
struct WorkoutsApp: App {
let persistenceController = PersistenceController.shared
let connectivityManager = WatchConnectivityManager.shared
init() {
// Set up Watch connectivity with Core Data context
connectivityManager.setViewContext(persistenceController.viewContext)
}
@State private var services = AppServices()
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistenceController.viewContext)
.environmentObject(connectivityManager)
RootGateView()
.environment(services)
.environment(services.syncEngine)
.modelContainer(services.container)
.task { await services.bootstrap() }
}
}
}