import CoreLocation import EventKit import Foundation private func isAllDay(_ components: DateComponents?) -> Bool { guard let components else { return false } return components.hour == nil && components.minute == nil && components.second == nil } public actor RemindersStore { private let eventStore = EKEventStore() private let calendar: Calendar public init(calendar: Calendar = .current) { self.calendar = calendar } public func requestAccess() async throws { let status = Self.authorizationStatus() switch status { case .notDetermined: let updated = try await requestAuthorization() if updated != .fullAccess { throw RemindCoreError.accessDenied } case .denied, .restricted: throw RemindCoreError.accessDenied case .writeOnly: throw RemindCoreError.writeOnlyAccess case .fullAccess: break } } public static func authorizationStatus() -> RemindersAuthorizationStatus { RemindersAuthorizationStatus(eventKitStatus: EKEventStore.authorizationStatus(for: .reminder)) } public func requestAuthorization() async throws -> RemindersAuthorizationStatus { let status = Self.authorizationStatus() switch status { case .notDetermined: let granted = try await requestFullAccess() return granted ? .fullAccess : .denied default: return status } } public func lists() async -> [ReminderList] { eventStore.calendars(for: .reminder).map { calendar in ReminderList(id: calendar.calendarIdentifier, title: calendar.title) } } public func defaultListName() -> String? { eventStore.defaultCalendarForNewReminders()?.title } public func reminders(in listName: String? = nil) async throws -> [ReminderItem] { let calendars: [EKCalendar] if let listName { calendars = eventStore.calendars(for: .reminder).filter { $0.title == listName } if calendars.isEmpty { throw RemindCoreError.listNotFound(listName) } } else { calendars = eventStore.calendars(for: .reminder) } return await fetchReminders(in: calendars) } public func createList(name: String) async throws -> ReminderList { let list = EKCalendar(for: .reminder, eventStore: eventStore) list.title = name guard let source = eventStore.defaultCalendarForNewReminders()?.source else { throw RemindCoreError.operationFailed("Unable to determine default reminder source") } list.source = source try eventStore.saveCalendar(list, commit: true) return ReminderList(id: list.calendarIdentifier, title: list.title) } public func renameList(oldName: String, newName: String) async throws { let calendar = try calendar(named: oldName) guard calendar.allowsContentModifications else { throw RemindCoreError.operationFailed("Cannot modify system list") } calendar.title = newName try eventStore.saveCalendar(calendar, commit: true) } public func deleteList(name: String) async throws { let calendar = try calendar(named: name) guard calendar.allowsContentModifications else { throw RemindCoreError.operationFailed("Cannot delete system list") } try eventStore.removeCalendar(calendar, commit: true) } public func createReminder(_ draft: ReminderDraft, listName: String) async throws -> ReminderItem { let calendar = try calendar(named: listName) let reminder = EKReminder(eventStore: eventStore) reminder.title = draft.title reminder.notes = draft.notes reminder.calendar = calendar reminder.priority = draft.priority.eventKitValue if let dueDate = draft.dueDate { reminder.dueDateComponents = calendarComponents(from: dueDate) } if let alarmDate = draft.alarmDate { reminder.addAlarm(EKAlarm(absoluteDate: alarmDate.date)) } else if let dueDate = draft.dueDate, !dueDate.isDateOnly { reminder.addAlarm(EKAlarm(absoluteDate: dueDate.date)) } if let recurrenceRule = draft.recurrenceRule { replaceRecurrence(on: reminder, with: recurrenceRule) } if let locationTrigger = draft.locationTrigger { reminder.addAlarm(try await locationAlarm(from: locationTrigger)) } try eventStore.save(reminder, commit: true) return item(from: reminder) } public func updateReminder(id: String, update: ReminderUpdate) async throws -> ReminderItem { let reminder = try reminder(withID: id) if let title = update.title { reminder.title = title } if let notes = update.notes { reminder.notes = notes } if let dueDateUpdate = update.dueDate { if let dueDate = dueDateUpdate { reminder.dueDateComponents = nil reminder.dueDateComponents = calendarComponents(from: dueDate) if update.alarmDate == nil && !dueDate.isDateOnly { replaceAlarms(on: reminder, with: dueDate.date) } } else { reminder.dueDateComponents = nil } } if let alarmDateUpdate = update.alarmDate { replaceAlarms(on: reminder, with: alarmDateUpdate?.date) } if let recurrenceUpdate = update.recurrenceRule { replaceRecurrence(on: reminder, with: recurrenceUpdate) } if let priority = update.priority { reminder.priority = priority.eventKitValue } if let listName = update.listName { reminder.calendar = try calendar(named: listName) } if let isCompleted = update.isCompleted { reminder.isCompleted = isCompleted } try eventStore.save(reminder, commit: true) return item(from: reminder) } public func completeReminders(ids: [String]) async throws -> [ReminderItem] { var updated: [ReminderItem] = [] for id in ids { let reminder = try reminder(withID: id) reminder.isCompleted = true try eventStore.save(reminder, commit: true) updated.append(item(from: reminder)) } return updated } public func deleteReminders(ids: [String]) async throws -> Int { var deleted = 0 for id in ids { let reminder = try reminder(withID: id) try eventStore.remove(reminder, commit: true) deleted += 1 } return deleted } } extension RemindersStore { private func requestFullAccess() async throws -> Bool { try await withCheckedThrowingContinuation { continuation in eventStore.requestFullAccessToReminders { granted, error in if let error { continuation.resume(throwing: error) return } continuation.resume(returning: granted) } } } private func fetchReminders(in calendars: [EKCalendar]) async -> [ReminderItem] { struct ReminderData: Sendable { let id: String let title: String let notes: String? let url: URL? let isCompleted: Bool let completionDate: Date? let creationDate: Date? let lastModifiedDate: Date? let priority: Int let dueDateComponents: DateComponents? let dueDateIsAllDay: Bool let alarmDate: Date? let recurrenceRule: RecurrenceRule? let locationTrigger: LocationTrigger? let listID: String let listName: String } let reminderData = await withCheckedContinuation { (continuation: CheckedContinuation<[ReminderData], Never>) in let predicate = eventStore.predicateForReminders(in: calendars) eventStore.fetchReminders(matching: predicate) { reminders in let data = (reminders ?? []).map { reminder in let components = reminder.dueDateComponents return ReminderData( id: reminder.calendarItemIdentifier, title: reminder.title ?? "", notes: reminder.notes, url: reminder.url, isCompleted: reminder.isCompleted, completionDate: reminder.completionDate, creationDate: reminder.creationDate, lastModifiedDate: reminder.lastModifiedDate, priority: Int(reminder.priority), dueDateComponents: components, dueDateIsAllDay: isAllDay(components), alarmDate: Self.alarmDate(from: reminder), recurrenceRule: Self.recurrenceRule(from: reminder), locationTrigger: Self.locationTrigger(from: reminder), listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) } continuation.resume(returning: data) } } return reminderData.map { data in ReminderItem( id: data.id, title: data.title, notes: data.notes, url: data.url, isCompleted: data.isCompleted, completionDate: data.completionDate, creationDate: data.creationDate, lastModifiedDate: data.lastModifiedDate, priority: ReminderPriority(eventKitValue: data.priority), dueDate: date(from: data.dueDateComponents), dueDateIsAllDay: data.dueDateIsAllDay, alarmDate: data.alarmDate, recurrenceRule: data.recurrenceRule, locationTrigger: data.locationTrigger, listID: data.listID, listName: data.listName ) } } private func reminder(withID id: String) throws -> EKReminder { guard let item = eventStore.calendarItem(withIdentifier: id) as? EKReminder else { throw RemindCoreError.reminderNotFound(id) } return item } private func calendar(named name: String) throws -> EKCalendar { let calendars = eventStore.calendars(for: .reminder).filter { $0.title == name } guard let calendar = calendars.first else { throw RemindCoreError.listNotFound(name) } return calendar } private func calendarComponents(from parsed: ParsedUserDate) -> DateComponents { let components: Set = parsed.isDateOnly ? [.year, .month, .day] : [.year, .month, .day, .hour, .minute, .second] var result = calendar.dateComponents(components, from: parsed.date) result.calendar = calendar result.timeZone = calendar.timeZone return result } private func date(from components: DateComponents?) -> Date? { guard let components else { return nil } return calendar.date(from: components) } private func item(from reminder: EKReminder) -> ReminderItem { let components = reminder.dueDateComponents return ReminderItem( id: reminder.calendarItemIdentifier, title: reminder.title ?? "", notes: reminder.notes, url: reminder.url, isCompleted: reminder.isCompleted, completionDate: reminder.completionDate, creationDate: reminder.creationDate, lastModifiedDate: reminder.lastModifiedDate, priority: ReminderPriority(eventKitValue: Int(reminder.priority)), dueDate: date(from: components), dueDateIsAllDay: isAllDay(components), alarmDate: Self.alarmDate(from: reminder), recurrenceRule: Self.recurrenceRule(from: reminder), locationTrigger: Self.locationTrigger(from: reminder), listID: reminder.calendar.calendarIdentifier, listName: reminder.calendar.title ) } private func replaceAlarms(on reminder: EKReminder, with date: Date?) { for alarm in reminder.alarms ?? [] { reminder.removeAlarm(alarm) } if let date { reminder.addAlarm(EKAlarm(absoluteDate: date)) } } private static func alarmDate(from reminder: EKReminder) -> Date? { reminder.alarms? .compactMap(\.absoluteDate) .min() } private func replaceRecurrence(on reminder: EKReminder, with rule: RecurrenceRule?) { for existing in reminder.recurrenceRules ?? [] { reminder.removeRecurrenceRule(existing) } guard let rule else { return } reminder.addRecurrenceRule( EKRecurrenceRule(recurrenceWith: rule.eventKitFrequency, interval: rule.interval, end: nil)) } private static func recurrenceRule(from reminder: EKReminder) -> RecurrenceRule? { guard let rule = reminder.recurrenceRules?.first else { return nil } guard let frequency = RecurrenceFrequency(eventKitFrequency: rule.frequency) else { return nil } return RecurrenceRule(frequency: frequency, interval: rule.interval) } private func locationAlarm(from trigger: LocationTrigger) async throws -> EKAlarm { let structuredLocation = EKStructuredLocation(title: trigger.address) let location: CLLocation if let latitude = trigger.latitude, let longitude = trigger.longitude { location = CLLocation(latitude: latitude, longitude: longitude) } else { let placemarks = try await CLGeocoder().geocodeAddressString(trigger.address) guard let geocodedLocation = placemarks.first?.location else { throw RemindCoreError.operationFailed("Could not geocode location: \(trigger.address)") } location = geocodedLocation } structuredLocation.geoLocation = location structuredLocation.radius = trigger.radius let alarm = EKAlarm() alarm.structuredLocation = structuredLocation alarm.proximity = trigger.proximity == .arriving ? .enter : .leave return alarm } private static func locationTrigger(from reminder: EKReminder) -> LocationTrigger? { guard let alarm = reminder.alarms?.first(where: { $0.structuredLocation != nil }), let structuredLocation = alarm.structuredLocation, let proximity = LocationProximity(eventKitProximity: alarm.proximity) else { return nil } let coordinate = structuredLocation.geoLocation?.coordinate return LocationTrigger( address: structuredLocation.title ?? "", latitude: coordinate?.latitude, longitude: coordinate?.longitude, radius: structuredLocation.radius, proximity: proximity ) } } extension RecurrenceFrequency { fileprivate init?(eventKitFrequency: EKRecurrenceFrequency) { switch eventKitFrequency { case .daily: self = .daily case .weekly: self = .weekly case .monthly: self = .monthly case .yearly: self = .yearly @unknown default: return nil } } fileprivate var eventKitFrequency: EKRecurrenceFrequency { switch self { case .daily: return .daily case .weekly: return .weekly case .monthly: return .monthly case .yearly: return .yearly } } } extension RecurrenceRule { fileprivate var eventKitFrequency: EKRecurrenceFrequency { frequency.eventKitFrequency } } extension LocationProximity { fileprivate init?(eventKitProximity: EKAlarmProximity) { switch eventKitProximity { case .enter: self = .arriving case .leave: self = .leaving default: return nil } } }