wip
This commit is contained in:
@ -15,6 +15,7 @@ struct ContentView: View {
|
|||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
TabView {
|
TabView {
|
||||||
WorkoutsView()
|
WorkoutsView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
@ -36,7 +37,8 @@ struct ContentView: View {
|
|||||||
Label("Settings", systemImage: "gear")
|
Label("Settings", systemImage: "gear")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.observeCloudKitChanges()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,6 +31,8 @@ final class Exercise {
|
|||||||
self.reps = reps
|
self.reps = reps
|
||||||
self.weight = weight
|
self.weight = weight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static let unnamed = "Unnamed Exercise"
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Exercise: EditableEntity {
|
extension Exercise: EditableEntity {
|
||||||
|
@ -17,6 +17,8 @@ final class Split {
|
|||||||
self.name = name
|
self.name = name
|
||||||
self.intro = intro
|
self.intro = intro
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static let unnamed = "Unnamed Split"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - EditableEntity Conformance
|
// MARK: - EditableEntity Conformance
|
||||||
@ -63,7 +65,7 @@ fileprivate struct SplitFormView: View {
|
|||||||
if let assignments = model.exercises, !assignments.isEmpty {
|
if let assignments = model.exercises, !assignments.isEmpty {
|
||||||
ForEach(assignments) { item in
|
ForEach(assignments) { item in
|
||||||
ListItem(
|
ListItem(
|
||||||
title: item.exercise?.name ?? "Unnamed",
|
title: item.exercise?.name ?? Exercise.unnamed,
|
||||||
subtitle: "\(item.sets) × \(item.reps) @ \(item.weight) lbs"
|
subtitle: "\(item.sets) × \(item.reps) @ \(item.weight) lbs"
|
||||||
)
|
)
|
||||||
.swipeActions {
|
.swipeActions {
|
||||||
|
33
Workouts/Schema/CloudKitSyncObserver.swift
Normal file
33
Workouts/Schema/CloudKitSyncObserver.swift
Normal file
@ -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<Exercise>())
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
struct InitialData {
|
struct DataLoader {
|
||||||
static let logger = AppLogger(
|
static let logger = AppLogger(
|
||||||
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Workouts",
|
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Workouts",
|
||||||
category: "InitialData"
|
category: "InitialData"
|
@ -2,21 +2,15 @@ import Foundation
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
final class WorkoutsContainer {
|
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 schema = Schema(versionedSchema: SchemaV1.self)
|
||||||
let configuration = ModelConfiguration(cloudKitDatabase: .automatic)
|
let configuration = ModelConfiguration(cloudKitDatabase: .automatic)
|
||||||
let container = try! ModelContainer(for: schema, migrationPlan: WorkoutsMigrationPlan.self, configurations: [configuration])
|
let container = try! ModelContainer(for: schema, migrationPlan: WorkoutsMigrationPlan.self, configurations: [configuration])
|
||||||
|
|
||||||
let context = ModelContext(container)
|
|
||||||
let descriptor = FetchDescriptor<Exercise>()
|
|
||||||
let results = try! context.fetch(descriptor)
|
|
||||||
|
|
||||||
if results.isEmpty {
|
|
||||||
shouldCreateDefaults = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return container
|
return container
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,7 +24,7 @@ final class WorkoutsContainer {
|
|||||||
let context = ModelContext(container)
|
let context = ModelContext(container)
|
||||||
|
|
||||||
// Create default data for previews
|
// Create default data for previews
|
||||||
InitialData.create(modelContext: context)
|
DataLoader.create(modelContext: context)
|
||||||
|
|
||||||
return container
|
return container
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -9,10 +9,17 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import CloudKit
|
||||||
|
|
||||||
|
enum AppStorageKeys {
|
||||||
|
static let iCloudSyncEnabled = "iCloudSyncEnabled"
|
||||||
|
}
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
|
@State private var showingPopulateData = false
|
||||||
|
@State private var showingClearAllDataConfirmation = false
|
||||||
|
|
||||||
var splitsCount: Int? { try? modelContext.fetchCount(FetchDescriptor<Split>()) }
|
var splitsCount: Int? { try? modelContext.fetchCount(FetchDescriptor<Split>()) }
|
||||||
var musclesCount: Int? { try? modelContext.fetchCount(FetchDescriptor<Muscle>()) }
|
var musclesCount: Int? { try? modelContext.fetchCount(FetchDescriptor<Muscle>()) }
|
||||||
@ -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")
|
.navigationTitle("Settings")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteAllObjects<T: PersistentModel>(ofType type: T.Type, from context: ModelContext) throws {
|
||||||
|
let descriptor = FetchDescriptor<T>()
|
||||||
|
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 {
|
struct ExercisesListView: View {
|
||||||
|
83
Workouts/Views/Workouts/WorkoutEditView.swift
Normal file
83
Workouts/Views/Workouts/WorkoutEditView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,9 +18,10 @@ struct WorkoutLogEditView: View {
|
|||||||
@State private var showingSaveConfirmation = false
|
@State private var showingSaveConfirmation = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
Section (header: Text("Exercise")) {
|
Section (header: Text("Exercise")) {
|
||||||
Text("\(workoutLog.exercise?.name ?? "Unnamed Exercise")")
|
Text("\(workoutLog.exercise?.name ?? Exercise.unnamed)")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +80,7 @@ struct WorkoutLogEditView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func updateSplit(from workoutLog: WorkoutLog) {
|
private func updateSplit(from workoutLog: WorkoutLog) {
|
||||||
let split = workoutLog.workout?.split
|
let split = workoutLog.workout?.split
|
||||||
|
@ -28,6 +28,10 @@ struct WorkoutLogView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
Section {
|
||||||
|
Text("Started \(workout.label)")
|
||||||
|
}
|
||||||
|
Section {
|
||||||
List {
|
List {
|
||||||
ForEach (sortedWorkoutLogs) { log in
|
ForEach (sortedWorkoutLogs) { log in
|
||||||
let badges = log.completed ? [Badge(text: "Completed", color: .green)] : []
|
let badges = log.completed ? [Badge(text: "Completed", color: .green)] : []
|
||||||
@ -71,7 +75,8 @@ struct WorkoutLogView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Workout")
|
}
|
||||||
|
.navigationTitle("\(workout.split?.name ?? Split.unnamed)")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(action: { showingAddSheet.toggle() }) {
|
Button(action: { showingAddSheet.toggle() }) {
|
||||||
|
@ -34,7 +34,10 @@ struct WorkoutsView: View {
|
|||||||
List {
|
List {
|
||||||
ForEach (workouts) { workout in
|
ForEach (workouts) { workout in
|
||||||
NavigationLink(destination: WorkoutLogView(workout: workout)) {
|
NavigationLink(destination: WorkoutLogView(workout: workout)) {
|
||||||
ListItem(title: workout.label)
|
ListItem(
|
||||||
|
title: workout.split?.name ?? Split.unnamed,
|
||||||
|
subtitle: workout.label
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
@ -61,9 +64,9 @@ struct WorkoutsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// .sheet(item: $itemToEdit) { item in
|
.sheet(item: $itemToEdit) { item in
|
||||||
// T.formView(for: item)
|
WorkoutEditView(workout: item)
|
||||||
// }
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Delete?",
|
"Delete?",
|
||||||
isPresented: Binding<Bool>(
|
isPresented: Binding<Bool>(
|
||||||
|
@ -10,17 +10,51 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import CloudKit
|
||||||
|
import CoreData
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct WorkoutsApp: App {
|
struct WorkoutsApp: App {
|
||||||
let container: ModelContainer
|
let container: ModelContainer
|
||||||
|
@State private var cloudKitObserver: NSObjectProtocol?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
var shouldCreateDefaults = false
|
self.container = WorkoutsContainer.create()
|
||||||
self.container = WorkoutsContainer.create(shouldCreateDefaults: &shouldCreateDefaults)
|
|
||||||
|
|
||||||
if shouldCreateDefaults {
|
// Set up CloudKit notification observation
|
||||||
InitialData.create(modelContext: ModelContext(container))
|
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)
|
.modelContainer(container)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extension to define the notification name
|
||||||
|
extension Notification.Name {
|
||||||
|
static let cloudKitDataDidChange = Notification.Name("cloudKitDataDidChange")
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user