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