// // CalendarManager.swift // DynamicIsland // // Created by Harsh Vardhan Goswami on 08/09/24. // import Defaults import EventKit import SwiftUI // MARK: - CalendarManager @MainActor class CalendarManager: ObservableObject { static let shared = CalendarManager() @Published var currentWeekStartDate: Date @Published var events: [EventModel] = [] @Published var allCalendars: [CalendarModel] = [] @Published var eventCalendars: [CalendarModel] = [] @Published var reminderLists: [CalendarModel] = [] @Published var selectedCalendarIDs: Set = [] @Published var calendarAuthorizationStatus: EKAuthorizationStatus = .notDetermined @Published var reminderAuthorizationStatus: EKAuthorizationStatus = .notDetermined private var selectedCalendars: [CalendarModel] = [] private let calendarService = CalendarService() private var lastEventsFetchDate: Date? private let reloadRefreshInterval: TimeInterval = 15 private var eventStoreChangedObserver: NSObjectProtocol? private var pendingEventStoreRefreshTask: Task? private var nextAllowedEventStoreRefresh: Date = .distantPast private var ignoreEventStoreChangesUntil: Date = .distantPast private let eventStoreChangeThrottle: TimeInterval = 20 private let selfInducedChangeSuppression: TimeInterval = 6 private let eventFetchLimiter = EventFetchLimiter() var hasCalendarAccess: Bool { isAuthorized(calendarAuthorizationStatus) } var hasReminderAccess: Bool { isAuthorized(reminderAuthorizationStatus) } private init() { currentWeekStartDate = CalendarManager.startOfDay(Date()) setupEventStoreChangedObserver() Task { await reloadCalendarAndReminderLists() } } deinit { if let observer = eventStoreChangedObserver { NotificationCenter.default.removeObserver(observer) } pendingEventStoreRefreshTask?.cancel() } private func setupEventStoreChangedObserver() { eventStoreChangedObserver = NotificationCenter.default.addObserver( forName: .EKEventStoreChanged, object: nil, queue: .main ) { [weak self] _ in self?.handleEventStoreChanged() } } private func handleEventStoreChanged() { Logger.log("CalendarManager: Event store changed notification received", category: .lifecycle) let now = Date() guard now >= ignoreEventStoreChangesUntil else { return } if now < nextAllowedEventStoreRefresh { let delay = max(nextAllowedEventStoreRefresh.timeIntervalSince(now), 0.05) scheduleEventStoreRefresh(after: delay) return } nextAllowedEventStoreRefresh = now.addingTimeInterval(eventStoreChangeThrottle) scheduleEventStoreRefresh(after: 0) } private func scheduleEventStoreRefresh(after delay: TimeInterval) { pendingEventStoreRefreshTask?.cancel() pendingEventStoreRefreshTask = Task { [weak self] in guard let self else { return } if delay > 0 { let nanoseconds = UInt64(delay * 1_000_000_000) try? await Task.sleep(nanoseconds: nanoseconds) } await self.performEventStoreRefresh() } } @MainActor private func performEventStoreRefresh() async { pendingEventStoreRefreshTask = nil await reloadCalendarAndReminderLists() await maybeRefreshEventsAfterReload() nextAllowedEventStoreRefresh = Date().addingTimeInterval(eventStoreChangeThrottle) ignoreEventStoreChangesUntil = Date().addingTimeInterval(selfInducedChangeSuppression) } @MainActor func reloadCalendarAndReminderLists() async { let allCalendars = await calendarService.calendars() eventCalendars = allCalendars.filter { !$0.isReminder } reminderLists = allCalendars.filter { $0.isReminder } self.allCalendars = allCalendars updateSelectedCalendars() } @MainActor private func maybeRefreshEventsAfterReload() async { guard hasCalendarAccess else { return } let now = Date() if let lastFetch = lastEventsFetchDate, now.timeIntervalSince(lastFetch) < reloadRefreshInterval { return } await updateEvents() } private func isAuthorized(_ status: EKAuthorizationStatus) -> Bool { switch status { case .authorized, .fullAccess: return true default: return false } } func checkCalendarAuthorization() async { let status = EKEventStore.authorizationStatus(for: .event) calendarAuthorizationStatus = status switch status { case .notDetermined: let granted = await calendarService.requestAccess(to: .event) calendarAuthorizationStatus = granted ? .fullAccess : .denied if granted { await reloadCalendarAndReminderLists() await updateEvents(force: true) } case .restricted, .denied: NSLog("Calendar access denied or restricted") case .authorized, .fullAccess: await reloadCalendarAndReminderLists() await updateEvents(force: true) case .writeOnly: NSLog("Calendar write only") @unknown default: NSLog("Unknown calendar authorization status") } } func checkReminderAuthorization() async { let status = EKEventStore.authorizationStatus(for: .reminder) reminderAuthorizationStatus = status switch status { case .notDetermined: let granted = await calendarService.requestAccess(to: .reminder) reminderAuthorizationStatus = granted ? .fullAccess : .denied if granted { await reloadCalendarAndReminderLists() } case .restricted, .denied: NSLog("Reminder access denied or restricted") case .authorized, .fullAccess: await reloadCalendarAndReminderLists() case .writeOnly: NSLog("Reminder write only") @unknown default: NSLog("Unknown reminder authorization status") } } func updateSelectedCalendars() { switch Defaults[.calendarSelectionState] { case .all: selectedCalendarIDs = Set(allCalendars.map { $0.id }) case .selected(let identifiers): selectedCalendarIDs = identifiers } selectedCalendars = allCalendars.filter { selectedCalendarIDs.contains($0.id) } } func getCalendarSelected(_ calendar: CalendarModel) -> Bool { selectedCalendarIDs.contains(calendar.id) } func setCalendarSelected(_ calendar: CalendarModel, isSelected: Bool) async { var selectionState = Defaults[.calendarSelectionState] switch selectionState { case .all: if !isSelected { let identifiers = Set(allCalendars.map { $0.id }).subtracting([calendar.id]) selectionState = .selected(identifiers) } case .selected(var identifiers): if isSelected { identifiers.insert(calendar.id) } else { identifiers.remove(calendar.id) } if identifiers.isEmpty || identifiers.count == allCalendars.count { selectionState = .all } else { selectionState = .selected(identifiers) } } Defaults[.calendarSelectionState] = selectionState updateSelectedCalendars() await updateEvents(force: true) } static func startOfDay(_ date: Date) -> Date { Calendar.current.startOfDay(for: date) } func updateCurrentDate(_ date: Date) async { currentWeekStartDate = Calendar.current.startOfDay(for: date) await updateEvents(force: true) } private func updateEvents(force: Bool = false) async { let now = Date() if !force, let lastFetch = lastEventsFetchDate, now.timeIntervalSince(lastFetch) < reloadRefreshInterval { return } Logger.log("CalendarManager: Updating events (force: \(force))", category: .lifecycle) let calendarIDs = selectedCalendars.map { $0.id } let startDate = currentWeekStartDate guard let endDate = Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate) else { return } let service = calendarService let events = await eventFetchLimiter.run { await service.events( from: startDate, to: endDate, calendars: calendarIDs ) } self.events = events lastEventsFetchDate = Date() } func setReminderCompleted(reminderID: String, completed: Bool) async { await calendarService.setReminderCompleted(reminderID: reminderID, completed: completed) await updateEvents(force: true) } } // MARK: - Event Fetch Limiter private actor EventFetchLimiter { private var waiters: [CheckedContinuation] = [] private var isRunning = false func run(_ operation: @escaping @Sendable () async -> T) async -> T { await waitTurn() defer { resumeNext() } return await operation() } private func waitTurn() async { if !isRunning { isRunning = true return } await withCheckedContinuation { continuation in waiters.append(continuation) } } private func resumeNext() { if waiters.isEmpty { isRunning = false return } let continuation = waiters.removeFirst() continuation.resume() } }