/* * Atoll (DynamicIsland) * Copyright (C) 2024-2026 Atoll Contributors * * Originally from boring.notch project * Modified and adapted for Atoll (DynamicIsland) * See NOTICE for details. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ 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 @Published var lockScreenEvents: [EventModel] = [] 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() private var lastLockScreenEventsFetchDate: Date? private let lockScreenRefreshInterval: TimeInterval = 15 private var lockScreenRefreshTask: Task? var hasCalendarAccess: Bool { isAuthorized(calendarAuthorizationStatus) } var hasReminderAccess: Bool { isAuthorized(reminderAuthorizationStatus) } private init() { currentWeekStartDate = CalendarManager.startOfDay(Date()) setupEventStoreChangedObserver() startLockScreenRefreshLoop() Task { await reloadCalendarAndReminderLists() } } deinit { if let observer = eventStoreChangedObserver { NotificationCenter.default.removeObserver(observer) } pendingEventStoreRefreshTask?.cancel() lockScreenRefreshTask?.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() await updateLockScreenEvents(force: true) 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) await updateLockScreenEvents(force: true) } case .restricted, .denied: NSLog("Calendar access denied or restricted") case .authorized, .fullAccess: await reloadCalendarAndReminderLists() await updateEvents(force: true) await updateLockScreenEvents(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) await updateLockScreenEvents(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) await updateLockScreenEvents(force: true) } func updateLockScreenEvents(force: Bool = false) async { let now = Date() if !force, let lastFetch = lastLockScreenEventsFetchDate, now.timeIntervalSince(lastFetch) < lockScreenRefreshInterval { return } let lookaheadRaw = Defaults[.lockScreenCalendarEventLookaheadWindow] .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() let isAllTimeLookahead = lookaheadRaw == "all_time" || lookaheadRaw == "all time" || lookaheadRaw == "alltime" let isRestOfDay = lookaheadRaw == "rest_of_day" || lookaheadRaw == "rest of the day" || lookaheadRaw == "restofday" func lookaheadMinutes(from raw: String) -> Int? { switch raw { case "15m", "15 min", "15 mins", "15min", "15mins": return 15 case "30m", "30 min", "30 mins", "30min", "30mins": return 30 case "1h", "1 hr", "1 hour", "1hour": return 60 case "3h", "3 hr", "3 hours", "3hours": return 180 case "6h", "6 hr", "6 hours", "6hours": return 360 case "12h", "12 hr", "12 hours", "12hours": return 720 default: return nil } } let calendar = Calendar.current let startOfToday = calendar.startOfDay(for: now) let startDate = calendar.date(byAdding: .day, value: -1, to: startOfToday) ?? startOfToday let endDate: Date if isAllTimeLookahead { endDate = calendar.date(byAdding: .day, value: 365, to: now) ?? now.addingTimeInterval(365 * 24 * 3600) } else if isRestOfDay { endDate = calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? startOfToday.addingTimeInterval(24 * 3600) } else if let minutes = lookaheadMinutes(from: lookaheadRaw) { endDate = calendar.date(byAdding: .minute, value: minutes, to: now) ?? now.addingTimeInterval(TimeInterval(minutes * 60)) } else { endDate = calendar.date(byAdding: .minute, value: 180, to: now) ?? now.addingTimeInterval(180 * 60) } let calendarIDs = allCalendars.map { $0.id } let service = calendarService let fetched = await eventFetchLimiter.run { await service.events(from: startDate, to: endDate, calendars: calendarIDs) } if lockScreenEvents == fetched { lastLockScreenEventsFetchDate = Date() return } withAnimation(.smooth(duration: 0.25)) { lockScreenEvents = fetched } lastLockScreenEventsFetchDate = Date() } private func startLockScreenRefreshLoop() { lockScreenRefreshTask?.cancel() lockScreenRefreshTask = Task { [weak self] in guard let self else { return } while !Task.isCancelled { try? await Task.sleep(nanoseconds: 60 * 1_000_000_000) if Task.isCancelled { break } guard self.hasCalendarAccess else { continue } await self.updateLockScreenEvents(force: false) } } } 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() } }