How to identify when a specific instance of a recurring event has changed?

Relatively new SwiftUI programmer here (~6 months). I've been at this problem for days, searching the web, Stack Overflow and GPT4. I feel like I must be missing something obvious.

Building an app with its own calendar system, which is meant to integrate with iOS calendar.

I have set it up so singular events changing in the app also reflect in the iOS calendar, and vice versa. I've done this using eventIdentifiers and storing them as properties. All good there.

My issue is with recurring events. From documentation:

Recurring event identifiers are the same for all occurrences. If you wish to differentiate between occurrences, you may want to use the start date.

So, if a user decides to change a recurring event in the iOS calendar from, say, Wednesdays at 5pm to Mondays at 5pm (for this and all future events), if I'm meant to use the startDate, but the user changed the startDate, how would I find the specific event in question?

In my app, the events are of custom type LessonBlock, and they have specific IDs. Recurring Events in my app are a series of unique LessonBlocks. I thought, maybe I could add the LessonBlock id to the recurring events in the iOS calendar - but it seems the only way I can do that is by adding it to the Notes section of a calendar event, which seems fragile and could lead to a poor user experience.

Current flow for single events:

  1. user changes event in iOS
  2. app gets notification when user comes back to the app.
  3. app checks for events using event identifiers (which are stored as properties in the LessonBlock code).
  4. if any times have changed, app updates those events.

This flow breaks if someone changes a recurring event in the iOS calendar, because all recurring events have the same ID and the eventID only returns the first instance.

I'm at the point where I think a user will only be able to change the recurring events from the app to the iOS calendar and not the other direction. I'm really hoping that's not the case.

I hope I've laid out the problem with enough detail. Below is the relevant code. Thanks again for your help.

Note: AppEnvironment is injected on the first view.

class AppEnvironment: ObservableObject {
    let calendarManager = iOSCalendarManager()
    
    @Published var showBanner: Bool = false
    @Published var bannerMessage: String = ""

    
    init() {
        print("AppEnvironment initialized.")
        print("Adding observer in NotificationCenter for calendar...")
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(eventStoreDidChange(_:)),
            name: .EKEventStoreChanged,
            object: nil
        )
        print("Done.")
        
        // This will hide UI Constraints errors that we don't care about
        let _ = UserDefaults.standard.set(false, forKey: "_UIConstraintBasedLayoutLogUnsatisfiable")
        
        // This will display the path for the document directory. Important for finding the folder that contains the Realm database
        print("Realm location:")
        let _ = print(FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path)
    }

    deinit {
        NotificationCenter.default.removeObserver(self, name: .EKEventStoreChanged, object: nil)
    }
    
    @objc private func eventStoreDidChange(_ notification: Notification) {
        calendarManager.checkCalendarAuthorizationStatus() { granted in
            if granted {
                print("AppEnvironment.eventStoreDidChange: Calendar access granted.")
                DispatchQueue.main.async {
                    self.calendarManager.fetchAndHandleEvents()
                }
            } else {
                DispatchQueue.main.async {
                    print("AppEnvironment.eventStoreDidChange: Calendar access is not authorized.")
                }
            }
        }
    }
import Foundation
import EventKit
import RealmSwift

/// This class handles requesting iOS calendar access, and CRUD operations for syncing the app's calendar with the iOS calendar operations.
class iOSCalendarManager {
    let eventStore = EKEventStore()
    @Published var isCalendarAccessDenied: Bool = false

    
    // Function to request access to the user's calendar
    func requestAccess(completion: @escaping (Bool, Error?) -> Void) {
        eventStore.requestAccess(to: .event) { granted, error in
            completion(granted, error)
        }
    }
    
    func checkCalendarAuthorizationStatus(completion: @escaping (Bool) -> Void) {
        let status = EKEventStore.authorizationStatus(for: .event)

        switch status {
            case .authorized, .fullAccess:
                self.isCalendarAccessDenied = false
                completion(true)
            case .notDetermined:
                self.requestAccess { granted, error in
                    DispatchQueue.main.async {
                        self.isCalendarAccessDenied = !granted
                    }
                    completion(granted)
                }
            case .denied, .restricted, .writeOnly:
                DispatchQueue.main.async {
                    self.isCalendarAccessDenied = true
                }
                completion(false)
            @unknown default:
                completion(false)
            }
    }
    
    // this function either returns the current musicStudyTracker calendar, or creates a new one if it doesn't exist.
    private func getOrCreateMusicStudyTrackerCalendar() -> EKCalendar? {
            let calendars = eventStore.calendars(for: .event)
            if let musicStudyTrackerCalendar = calendars.first(where: { $0.title == "MusicStudyTracker" }) {
                print("MusicStudyTracker calendar exists already.")
                return musicStudyTrackerCalendar
            } else {
                print("Creating new calendar MusicStudyTracker")
                let newCalendar = EKCalendar(for: .event, eventStore: eventStore)
                newCalendar.title = "MusicStudyTracker"
                newCalendar.source = eventStore.defaultCalendarForNewEvents?.source // Set the source to the current default

                do {
                    try eventStore.saveCalendar(newCalendar, commit: true)
                    return newCalendar
                } catch {
                    print("Error creating calendar: \(error)")
                    return nil
                }
            }
        }
    
    // Function to add an event to the calendar
    func addEventToCalendarFromApp(title: String, startDate: Date, endDate: Date, completion: @escaping (Bool, String?, Error?) -> Void) {
        guard let calendar = getOrCreateMusicStudyTrackerCalendar() else {
                    completion(false, nil, nil)
                    return
                }

        let event = EKEvent(eventStore: eventStore)
        event.calendar = calendar
        event.title = title
        event.startDate = startDate
        event.endDate = endDate

        do {
            try eventStore.save(event, span: .thisEvent)
            let eventIdentifier = event.eventIdentifier // Get the event identifier
            completion(true, eventIdentifier, nil) // Return success with the event identifier
        } catch let error {
            completion(false, nil, error) // Return failure with the error
        }
    }
    
    func addEventToAppFromCalendar() {
        // TODO: implement addEventToAppFromCalendar
    }
    
    func deleteSingleCalendarEvent(fromLessonID lessonID: ObjectId) {
        guard let lesson = RealmManager.fetchLessonBlock(withID: lessonID) else {
            print("Error fetching lesson with lessonID \(lessonID)")
            return
        }
        
        print("Deleting lesson in calendar matching lessonID \(lessonID)")
        
        let eventId = lesson.iOSeventID
        print("and iOS eventId \(String(describing: eventId))")
        
        let startDate = lesson.startTime
        let endDate = lesson.endTime
        
        guard let calendar = getOrCreateMusicStudyTrackerCalendar() else {
                print("Could not find or create the MusicStudyTracker calendar.")
                return
            }
        
        // Fetch events matching the ID within the date range.
        let predicate = eventStore.predicateForEvents(withStart: startDate, end: endDate, calendars: [calendar])
        let events = eventStore.events(matching: predicate)
        
        // Find and delete the specific event by its identifier.
        if let event = events.first(where: { $0.eventIdentifier == eventId }) {
            do {
                try eventStore.remove(event, span: .thisEvent, commit: true)
                print("Successfully deleted the event with identifier \(String(describing: eventId)) from the calendar.")
            } catch {
                print("Error deleting the event: \(error)")
            }
        } else {
            print("No event found with the identifier \(String(describing: eventId)).")
        }
        
    }
    
    func fetchAndHandleEvents() {
        print("fetchAndHandleEvents() called.")

        do {
            print("Getting the realm...")
            // Attempt to access the realm
            let realm = try Realm()
            print("Realm fetched. Getting lessonBlocks...")
            // Get all the lesson blocks
            let lessonBlocks = realm.objects(LessonBlock.self)

            print("lessonBlocks fetched. Creating dictionary to map iOS event IDs...")
            // Create a dictionary mapping from iOSeventID to LessonBlock
            var lessonDictionary = [String: LessonBlock]()
            for lesson in lessonBlocks {
                if let eventID = lesson.iOSeventID {
                    lessonDictionary[eventID] = lesson
                }
            }
            
            print("Setting start date...")
            // Filter for events from today through a year from now. (Need to update this to include a year ago, I think)
            let startDate = Date() // Starting from today
            print("Setting end date...")

            guard let endDate = Calendar.current.date(byAdding: .year, value: 1, to: startDate) else {
                print("Failed to calculate the end date.")
                return // Handle the error as appropriate, perhaps by exiting the function early.
            }

            let events = self.fetchEventsFromIOSCalendar(startDate: startDate, endDate: endDate)
            print("Done. Updating app from iOS calendar...")
            
            self.updateAppCalendarFromIOSCalendar(events: events, lessonDictionary: lessonDictionary)
            
        } catch let error {
            print("An error occurred while initializing Realm or accessing its objects: \(error)")
        }
    }
    
    func fetchEventsFromIOSCalendar(startDate: Date, endDate: Date) -> [EKEvent] {
        
        print("Fetching MusicStudyTracker calendar, creating if necessary...")
        guard let calendar = getOrCreateMusicStudyTrackerCalendar() else {
            print("Failed to get or create the MusicStudyTracker calendar.")
            return []  // Return an empty array if there is no calendar
        }
        
        print("Creating predicate with filter...")
        let predicate = eventStore.predicateForEvents(withStart: startDate, end: endDate, calendars: [calendar])
        print("Getting events from eventStore...")
        let events = eventStore.events(matching: predicate)
        
        return events
    }

        
    func updateAppCalendarFromIOSCalendar(events: [EKEvent], lessonDictionary: [String:LessonBlock]) {
        DispatchQueue.main.async {
            if events.isEmpty {
                print("No events found.")
            } else {
                // Update app lessons with changed details from iOS calendar events
                for event in events {
                    // If there's a lesson matching the iOS event identifier
                    if let identifier = event.eventIdentifier, let lessonBlock = lessonDictionary[identifier] {
                        // Compare and update
                        print("Found a matching event with identifier \(identifier).")
                        self.updateLessonFromCalendar(event: event, lessonBlock: lessonBlock)
                    } else {
                        // No matching lesson found for the event in the iOS calendar
                        print("No matching lesson block found for event with identifier \(event.eventIdentifier ?? "Unknown").")
                    }
                }

            }
        }
    }

 for (identifier, lesson) in lessonDictionary {
            if !eventIdentifiers.contains(identifier) {
                do {
                    let realm = try Realm()
                    
                    try realm.write {
                        // delete lesson
                        realm.delete(lesson)
                        print("Deleted lesson with identifier \(identifier) from the database.")
                    }
                } catch {
                    print("Error deleting lesson from realm: \(error)")
                }

  // this function updates a lesson in the app when the iOS calendar changes
    func updateLessonFromCalendar(event: EKEvent, lessonBlock: LessonBlock) {
        print("CompareAndUpdate() called.")
        do {
                let realm = try Realm()
                // Begin a write transaction to update the lesson block with changes from the event
                try realm.write {
                    // Compare and update properties
                    if event.startDate != lessonBlock.startTime {
                        print("StartTime has changed in iOS. Updating realm...")
                        lessonBlock.startTime = event.startDate
                    }
                    if event.endDate != lessonBlock.endTime {
                        print("StartTime has changed in iOS. Updating realm...")
                        lessonBlock.endTime = event.endDate
                    }
                    
                    // ...continue for other properties
                }
            } catch {
                print("An error occurred while updating the Realm: \(error)")
            }
    }
    
    // this function updates the iOS calendar's events when a lesson in the app changes
    func updateCalendarFromLesson(with lessonBlock: LessonBlock) {
        DispatchQueue.main.async {
            do {
                let realm = try Realm() // Get a Realm instance for this thread
                
                // Safely get the lessonBlock within the thread
                guard let lessonBlock = realm.object(ofType: LessonBlock.self, forPrimaryKey: lessonBlock.id) else {
                    print("LessonBlock with ID \(lessonBlock.id) does not exist.")
                    return
                }
                
                
                // Ensure the event identifier exists
                guard let eventIdentifier = lessonBlock.iOSeventID,
                      let event = self.eventStore.event(withIdentifier: eventIdentifier) else {
                    print("Event identifier not found or event does not exist.")
                    return
                }
                
                // Update event properties from the lesson block
                if event.startDate != lessonBlock.startTime {
                    event.startDate = lessonBlock.startTime
                }
                
                if event.endDate != lessonBlock.endTime {
                    event.endDate = lessonBlock.endTime
                }
                
                // if there's a student associated with the lesson
                if let studentID = lessonBlock.studentID {
                    // get the full name
                    let studentName = RealmManager.getStudentFullName(studentID: studentID) ?? "Music Lesson"
                    
                    // update the event title if it is not already the student's full name
                    if event.title != studentName {
                        event.title = studentName
                    }
                }
                
                
                // ...update other properties as needed
                
                // Save the updated event
                do {
                    try self.eventStore.save(event, span: .thisEvent, commit: true)
                    print("Event updated successfully in the calendar.")
                } catch {
                    print("Failed to save the event: \(error)")
                }
                
            } catch {
                print("Failed to init Realm: \(error.localizedDescription)")
            }
        }
  
    }
}
class LessonBlock: Object, Identifiable {
    @Persisted(primaryKey: true) var id: ObjectId
    @Persisted var startTime: Date = Date()
    @Persisted var endTime: Date = Date()
    @Persisted var studentID: ObjectId? // reference to the student's ID
    @Persisted var attendanceRecord: AttendanceRecord = .notMarked // attendance defaults to notMarked
    @Persisted var createdByRecurringLesson: Bool = false // set to true by RecurringLesson
    @Persisted var iOSeventID: String? // Stores the identifier for the corresponding event in the iOS calendar

    // Linking to ScheduleList
    @Persisted(originProperty: "lessonBlocks") var assignee: LinkingObjects<ScheduleList>
    
    // Linking to RecurringLesson
    @Persisted(originProperty: "lessonBlocks") var createdBy: LinkingObjects<RecurringLesson>
}

// More to LessonBlock but that's the relevant portion

In terms of implementing code to get the recurring lessons to sync up, I haven't yet, because I'm stuck on the logic portion of it. It would all be much easier if each recurring event in the iOS calendar had its own unique ID.

Upvotes: 0

Views: 196

Answers (0)

Related Questions