From 39fd45e03fb48f9c48efbc78f34b80ad48016fb9 Mon Sep 17 00:00:00 2001 From: rzen Date: Mon, 14 Jul 2025 10:22:49 -0400 Subject: [PATCH] wip --- Workouts/ContentView.swift | 40 +++---- Workouts/Models/Exercise.swift | 2 + Workouts/Models/Split.swift | 4 +- Workouts/Schema/CloudKitSyncObserver.swift | 33 ++++++ .../{InitialData.swift => DataLoader.swift} | 2 +- Workouts/Schema/WorkoutsContainer.swift | 18 +-- Workouts/Views/Settings/SettingsView.swift | 80 +++++++++++++- Workouts/Views/Workouts/WorkoutEditView.swift | 83 ++++++++++++++ .../Views/Workouts/WorkoutLogEditView.swift | 104 +++++++++--------- Workouts/Views/Workouts/WorkoutLogView.swift | 77 +++++++------ Workouts/Views/Workouts/WorkoutsView.swift | 11 +- Workouts/WorkoutsApp.swift | 49 ++++++++- 12 files changed, 373 insertions(+), 130 deletions(-) create mode 100644 Workouts/Schema/CloudKitSyncObserver.swift rename Workouts/Schema/{InitialData.swift => DataLoader.swift} (99%) create mode 100644 Workouts/Views/Workouts/WorkoutEditView.swift diff --git a/Workouts/ContentView.swift b/Workouts/ContentView.swift index 6bcfb79..07c2fb4 100644 --- a/Workouts/ContentView.swift +++ b/Workouts/ContentView.swift @@ -15,28 +15,30 @@ struct ContentView: View { @Environment(\.modelContext) private var modelContext var body: some View { - TabView { - WorkoutsView() - .tabItem { - Label("Workout", systemImage: "figure.strengthtraining.traditional") - } + NavigationView { + TabView { + WorkoutsView() + .tabItem { + Label("Workout", systemImage: "figure.strengthtraining.traditional") + } - - // Reports Tab - NavigationStack { - Text("Reports Placeholder") - .navigationTitle("Reports") - } - .tabItem { - Label("Reports", systemImage: "chart.bar") - } - - SettingsView() - .tabItem { - Label("Settings", systemImage: "gear") + + // Reports Tab + NavigationStack { + Text("Reports Placeholder") + .navigationTitle("Reports") } + .tabItem { + Label("Reports", systemImage: "chart.bar") + } + + SettingsView() + .tabItem { + Label("Settings", systemImage: "gear") + } + } } - + .observeCloudKitChanges() } } diff --git a/Workouts/Models/Exercise.swift b/Workouts/Models/Exercise.swift index 3cdb608..cd0dcae 100644 --- a/Workouts/Models/Exercise.swift +++ b/Workouts/Models/Exercise.swift @@ -31,6 +31,8 @@ final class Exercise { self.reps = reps self.weight = weight } + + static let unnamed = "Unnamed Exercise" } extension Exercise: EditableEntity { diff --git a/Workouts/Models/Split.swift b/Workouts/Models/Split.swift index a0179db..780571f 100644 --- a/Workouts/Models/Split.swift +++ b/Workouts/Models/Split.swift @@ -17,6 +17,8 @@ final class Split { self.name = name self.intro = intro } + + static let unnamed = "Unnamed Split" } // MARK: - EditableEntity Conformance @@ -63,7 +65,7 @@ fileprivate struct SplitFormView: View { if let assignments = model.exercises, !assignments.isEmpty { ForEach(assignments) { item in ListItem( - title: item.exercise?.name ?? "Unnamed", + title: item.exercise?.name ?? Exercise.unnamed, subtitle: "\(item.sets) × \(item.reps) @ \(item.weight) lbs" ) .swipeActions { diff --git a/Workouts/Schema/CloudKitSyncObserver.swift b/Workouts/Schema/CloudKitSyncObserver.swift new file mode 100644 index 0000000..09b6131 --- /dev/null +++ b/Workouts/Schema/CloudKitSyncObserver.swift @@ -0,0 +1,33 @@ +import SwiftUI +import SwiftData + +/// A view modifier that refreshes the view when CloudKit data changes +struct CloudKitSyncObserver: ViewModifier { + @Environment(\.modelContext) private var modelContext + @State private var refreshID = UUID() + + func body(content: Content) -> some View { + content + .id(refreshID) // Force view refresh when this changes + .onReceive(NotificationCenter.default.publisher(for: .cloudKitDataDidChange)) { _ in + // When we receive a notification that CloudKit data changed: + // 1. Create a new UUID to force view refresh + refreshID = UUID() + + // 2. Optionally, you can also manually refresh the model context + // This is sometimes needed for complex relationships + Task { @MainActor in + try? modelContext.fetch(FetchDescriptor()) + // Add other model types as needed + } + } + } +} + +// Extension to make it easier to use the modifier +extension View { + /// Adds observation for CloudKit sync changes and refreshes the view when changes occur + func observeCloudKitChanges() -> some View { + self.modifier(CloudKitSyncObserver()) + } +} diff --git a/Workouts/Schema/InitialData.swift b/Workouts/Schema/DataLoader.swift similarity index 99% rename from Workouts/Schema/InitialData.swift rename to Workouts/Schema/DataLoader.swift index cce3901..a9378e8 100644 --- a/Workouts/Schema/InitialData.swift +++ b/Workouts/Schema/DataLoader.swift @@ -1,7 +1,7 @@ import Foundation import SwiftData -struct InitialData { +struct DataLoader { static let logger = AppLogger( subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Workouts", category: "InitialData" diff --git a/Workouts/Schema/WorkoutsContainer.swift b/Workouts/Schema/WorkoutsContainer.swift index 426b36c..813f572 100644 --- a/Workouts/Schema/WorkoutsContainer.swift +++ b/Workouts/Schema/WorkoutsContainer.swift @@ -2,21 +2,15 @@ import Foundation import SwiftData final class WorkoutsContainer { - static let logger = AppLogger(subsystem: "Workouts", category: "WorkoutsContainer") + static let logger = AppLogger( + subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Workouts", + category: "WorkoutsContainer" + ) - static func create(shouldCreateDefaults: inout Bool) -> ModelContainer { + static func create() -> ModelContainer { let schema = Schema(versionedSchema: SchemaV1.self) let configuration = ModelConfiguration(cloudKitDatabase: .automatic) let container = try! ModelContainer(for: schema, migrationPlan: WorkoutsMigrationPlan.self, configurations: [configuration]) - - let context = ModelContext(container) - let descriptor = FetchDescriptor() - let results = try! context.fetch(descriptor) - - if results.isEmpty { - shouldCreateDefaults = true - } - return container } @@ -30,7 +24,7 @@ final class WorkoutsContainer { let context = ModelContext(container) // Create default data for previews - InitialData.create(modelContext: context) + DataLoader.create(modelContext: context) return container } catch { diff --git a/Workouts/Views/Settings/SettingsView.swift b/Workouts/Views/Settings/SettingsView.swift index 5af5cc9..783b3d5 100644 --- a/Workouts/Views/Settings/SettingsView.swift +++ b/Workouts/Views/Settings/SettingsView.swift @@ -9,10 +9,17 @@ import SwiftUI import SwiftData +import CloudKit + +enum AppStorageKeys { + static let iCloudSyncEnabled = "iCloudSyncEnabled" +} struct SettingsView: View { @Environment(\.modelContext) private var modelContext - + + @State private var showingPopulateData = false + @State private var showingClearAllDataConfirmation = false var splitsCount: Int? { try? modelContext.fetchCount(FetchDescriptor()) } var musclesCount: Int? { try? modelContext.fetchCount(FetchDescriptor()) } @@ -70,10 +77,81 @@ struct SettingsView: View { } } } + + Section(header: Text("Developer")) { + + Button(action: { + showingPopulateData = true + }) { + HStack { + Label("Populate Data", systemImage: "plus") + Spacer() + } + } + .confirmationDialog( + "Populate Data?", + isPresented: $showingPopulateData, + titleVisibility: .hidden + ) { + Button("Populate Data") { + DataLoader.create(modelContext: modelContext) + } + Button("Cancel", role: .cancel) {} +// } message: { +// Text("This action cannot be undone. All data will be permanently deleted.") + } + + Button(action: { + showingClearAllDataConfirmation = true + }) { + HStack { + Label("Clear All Data", systemImage: "trash") + Spacer() + } + } + .foregroundColor(.red) + .confirmationDialog( + "Clear All Data?", + isPresented: $showingClearAllDataConfirmation, + titleVisibility: .visible + ) { + Button("Clear All Data", role: .destructive) { + clearAllData() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This action cannot be undone. All data will be permanently deleted.") + } + } } .navigationTitle("Settings") } } + + func deleteAllObjects(ofType type: T.Type, from context: ModelContext) throws { + let descriptor = FetchDescriptor() + let allObjects = try context.fetch(descriptor) + for object in allObjects { + context.delete(object) + } + try context.save() + } + + private func clearAllData () { + do { + try deleteAllObjects(ofType: ExerciseType.self, from: modelContext) + try deleteAllObjects(ofType: Exercise.self, from: modelContext) + try deleteAllObjects(ofType: Muscle.self, from: modelContext) + try deleteAllObjects(ofType: MuscleGroup.self, from: modelContext) + try deleteAllObjects(ofType: Split.self, from: modelContext) + try deleteAllObjects(ofType: SplitExerciseAssignment.self, from: modelContext) + try deleteAllObjects(ofType: Workout.self, from: modelContext) + try deleteAllObjects(ofType: WorkoutLog.self, from: modelContext) + try modelContext.save() + } catch { + print("Failed to clear all data: \(error)") + } + } } struct ExercisesListView: View { diff --git a/Workouts/Views/Workouts/WorkoutEditView.swift b/Workouts/Views/Workouts/WorkoutEditView.swift new file mode 100644 index 0000000..996d2ea --- /dev/null +++ b/Workouts/Views/Workouts/WorkoutEditView.swift @@ -0,0 +1,83 @@ +// +// WorkoutEditView.swift +// Workouts +// +// Created by rzen on 7/14/25 at 7:35 AM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import SwiftData + +struct WorkoutEditView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @State var workout: Workout + @State var endDateHidden: Bool = false + + var body: some View { + NavigationStack { + Form { + Section (header: Text("Split")) { + Text("\(workout.split?.name ?? Split.unnamed)") + } + + Section (header: Text("Start/End")) { + DatePicker("Started", selection: $workout.start) + Toggle("Workout Ended", isOn: Binding( + get: { workout.end != nil }, + set: { newValue in + withAnimation { + if newValue { + workout.end = Date() + endDateHidden = false + } else { + workout.end = nil + endDateHidden = true + } + } + } + )) + if !endDateHidden { + DatePicker("Ended", selection: $workout.start) + } + } + .onAppear { + endDateHidden = workout.end == nil + } + + Section (header: Text("Workout Log")) { + if let workoutLogs = workout.logs { + List { + ForEach (workoutLogs) { log in + ListItem( + title: log.exercise?.name ?? Exercise.unnamed, + subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs" + ) + } + } + } else { + Text("No workout logs yet") + } + } + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + try? modelContext.save() + dismiss() + } + } + } + } + } +} + diff --git a/Workouts/Views/Workouts/WorkoutLogEditView.swift b/Workouts/Views/Workouts/WorkoutLogEditView.swift index 4911f42..2dcb092 100644 --- a/Workouts/Views/Workouts/WorkoutLogEditView.swift +++ b/Workouts/Views/Workouts/WorkoutLogEditView.swift @@ -18,64 +18,66 @@ struct WorkoutLogEditView: View { @State private var showingSaveConfirmation = false var body: some View { - Form { - Section (header: Text("Exercise")) { - Text("\(workoutLog.exercise?.name ?? "Unnamed Exercise")") - .font(.headline) - } - - Section(header: Text("Sets/Reps")) { - Stepper("Sets: \(workoutLog.sets)", value: $workoutLog.sets, in: 1...10) - Stepper("Reps: \(workoutLog.reps)", value: $workoutLog.reps, in: 1...50) - } - - Section(header: Text("Weight")) { - HStack { - VStack(alignment: .center) { - Text("\(workoutLog.weight) lbs") - .font(.headline) + NavigationStack { + Form { + Section (header: Text("Exercise")) { + Text("\(workoutLog.exercise?.name ?? Exercise.unnamed)") + .font(.headline) + } + + Section(header: Text("Sets/Reps")) { + Stepper("Sets: \(workoutLog.sets)", value: $workoutLog.sets, in: 1...10) + Stepper("Reps: \(workoutLog.reps)", value: $workoutLog.reps, in: 1...50) + } + + Section(header: Text("Weight")) { + HStack { + VStack(alignment: .center) { + Text("\(workoutLog.weight) lbs") + .font(.headline) + } + Spacer() + VStack(alignment: .trailing) { + Stepper("±1", value: $workoutLog.weight, in: 0...1000) + Stepper("±5", value: $workoutLog.weight, in: 0...1000, step: 5) + } + .frame(width: 130) } - Spacer() - VStack(alignment: .trailing) { - Stepper("±1", value: $workoutLog.weight, in: 0...1000) - Stepper("±5", value: $workoutLog.weight, in: 0...1000, step: 5) - } - .frame(width: 130) } } - } - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + showingSaveConfirmation = true + } + } + } + .confirmationDialog("Save Options", isPresented: $showingSaveConfirmation) { + Button("Save Workout Log Only") { + try? modelContext.save() dismiss() } - } - - ToolbarItem(placement: .navigationBarTrailing) { - Button("Save") { - showingSaveConfirmation = true + + Button("Save Workout Log and Update Split") { + // Save the workout log + try? modelContext.save() + + // Update the split with this workout log's data + // Note: Implementation depends on how splits are updated in your app + updateSplit(from: workoutLog) + + dismiss() } - } - } - .confirmationDialog("Save Options", isPresented: $showingSaveConfirmation) { - Button("Save Workout Log Only") { - try? modelContext.save() - dismiss() - } - - Button("Save Workout Log and Update Split") { - // Save the workout log - try? modelContext.save() - // Update the split with this workout log's data - // Note: Implementation depends on how splits are updated in your app - updateSplit(from: workoutLog) - - dismiss() - } - - Button("Cancel", role: .cancel) { - // Do nothing, dialog will dismiss + Button("Cancel", role: .cancel) { + // Do nothing, dialog will dismiss + } } } } diff --git a/Workouts/Views/Workouts/WorkoutLogView.swift b/Workouts/Views/Workouts/WorkoutLogView.swift index 9bd5d1b..0de9e77 100644 --- a/Workouts/Views/Workouts/WorkoutLogView.swift +++ b/Workouts/Views/Workouts/WorkoutLogView.swift @@ -28,50 +28,55 @@ struct WorkoutLogView: View { var body: some View { Form { - List { - ForEach (sortedWorkoutLogs) { log in - let badges = log.completed ? [Badge(text: "Completed", color: .green)] : [] - ListItem( - title: log.exercise?.name ?? "Untitled Exercise", - subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs", - badges: badges - ) - .swipeActions(edge: .leading, allowsFullSwipe: false) { - if (log.completed) { - Button { - log.completed = false - try? modelContext.save() - } label: { - Label("Complete", systemImage: "circle.fill") + Section { + Text("Started \(workout.label)") + } + Section { + List { + ForEach (sortedWorkoutLogs) { log in + let badges = log.completed ? [Badge(text: "Completed", color: .green)] : [] + ListItem( + title: log.exercise?.name ?? "Untitled Exercise", + subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs", + badges: badges + ) + .swipeActions(edge: .leading, allowsFullSwipe: false) { + if (log.completed) { + Button { + log.completed = false + try? modelContext.save() + } label: { + Label("Complete", systemImage: "circle.fill") + } + .tint(.green) + } else { + Button { + log.completed = true + try? modelContext.save() + } label: { + Label("Reset", systemImage: "checkmark.circle.fill") + } + .tint(.green) } - .tint(.green) - } else { - Button { - log.completed = true - try? modelContext.save() + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + itemToDelete = log } label: { - Label("Reset", systemImage: "checkmark.circle.fill") + Label("Delete", systemImage: "trash") } - .tint(.green) + Button { + itemToEdit = log + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.indigo) } } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - itemToDelete = log - } label: { - Label("Delete", systemImage: "trash") - } - Button { - itemToEdit = log - } label: { - Label("Edit", systemImage: "pencil") - } - .tint(.indigo) - } } } } - .navigationTitle("Workout") + .navigationTitle("\(workout.split?.name ?? Split.unnamed)") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: { showingAddSheet.toggle() }) { diff --git a/Workouts/Views/Workouts/WorkoutsView.swift b/Workouts/Views/Workouts/WorkoutsView.swift index 96ef67e..4a4b418 100644 --- a/Workouts/Views/Workouts/WorkoutsView.swift +++ b/Workouts/Views/Workouts/WorkoutsView.swift @@ -34,7 +34,10 @@ struct WorkoutsView: View { List { ForEach (workouts) { workout in NavigationLink(destination: WorkoutLogView(workout: workout)) { - ListItem(title: workout.label) + ListItem( + title: workout.split?.name ?? Split.unnamed, + subtitle: workout.label + ) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { @@ -61,9 +64,9 @@ struct WorkoutsView: View { } } } -// .sheet(item: $itemToEdit) { item in -// T.formView(for: item) -// } + .sheet(item: $itemToEdit) { item in + WorkoutEditView(workout: item) + } .confirmationDialog( "Delete?", isPresented: Binding( diff --git a/Workouts/WorkoutsApp.swift b/Workouts/WorkoutsApp.swift index 5ffb2a1..724e8f9 100644 --- a/Workouts/WorkoutsApp.swift +++ b/Workouts/WorkoutsApp.swift @@ -10,17 +10,51 @@ import SwiftUI import SwiftData +import CloudKit +import CoreData @main struct WorkoutsApp: App { - let container: ModelContainer + let container: ModelContainer + @State private var cloudKitObserver: NSObjectProtocol? init() { - var shouldCreateDefaults = false - self.container = WorkoutsContainer.create(shouldCreateDefaults: &shouldCreateDefaults) + self.container = WorkoutsContainer.create() - if shouldCreateDefaults { - InitialData.create(modelContext: ModelContext(container)) + // Set up CloudKit notification observation + setupCloudKitObservation() + } + + private func setupCloudKitObservation() { + // Access the underlying NSPersistentCloudKitContainer + if let persistentContainer = Mirror(reflecting: container).descendant("persistentContainer") as? NSPersistentContainer { + // Register for remote change notifications + cloudKitObserver = NotificationCenter.default.addObserver( + forName: NSPersistentCloudKitContainer.eventChangedNotification, + object: persistentContainer, + queue: .main + ) { notification in + // Handle the notification + self.handleCloudKitNotification(notification) + } + } + } + + private func handleCloudKitNotification(_ notification: Notification) { + guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else { + return + } + + // Log the event for debugging + print("CloudKit event: \(event.type), \(event.succeeded ? "succeeded" : "failed")") + + // If the event was a successful import, refresh the UI + if event.type == .import, event.succeeded { + // Create a new context to force UI refresh + let context = ModelContext(container) + + // Trigger UI refresh by posting a notification that views can observe + NotificationCenter.default.post(name: .cloudKitDataDidChange, object: nil) } } @@ -31,3 +65,8 @@ struct WorkoutsApp: App { .modelContainer(container) } } + +// Extension to define the notification name +extension Notification.Name { + static let cloudKitDataDidChange = Notification.Name("cloudKitDataDidChange") +}