// // EventKitBridge.swift // Poche // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // import Foundation import EventKit enum BridgeError: Error { case remindersAccessDenied case calendarAccessDenied var userMessage: String { switch self { case .remindersAccessDenied: "Poche n'a pas accès aux Rappels. Autorise l'accès dans Réglages → Confidentialité → Rappels." case .calendarAccessDenied: "Poche n'a pas accès au Calendrier. Autorise l'accès dans Réglages → Confidentialité → Calendriers." } } } /// EventKit bridge. Writes are reachable only through ActionExecutor after /// user confirmation. Permissions are requested at the moment of need, /// never at launch (CLAUDE.md §4). @MainActor final class EventKitBridge { private let eventStore = EKEventStore() // MARK: - Permissions private func ensureRemindersAccess() async throws { switch EKEventStore.authorizationStatus(for: .reminder) { case .fullAccess: return case .notDetermined: let granted = (try? await eventStore.requestFullAccessToReminders()) ?? false guard granted else { throw BridgeError.remindersAccessDenied } default: throw BridgeError.remindersAccessDenied } } private func ensureCalendarAccess() async throws { switch EKEventStore.authorizationStatus(for: .event) { case .fullAccess: return case .notDetermined: let granted = (try? await eventStore.requestFullAccessToEvents()) ?? false guard granted else { throw BridgeError.calendarAccessDenied } default: throw BridgeError.calendarAccessDenied } } // MARK: - Writes (ActionExecutor only) func createReminder(_ draft: ReminderDraft) async throws { try await ensureRemindersAccess() let reminder = EKReminder(eventStore: eventStore) reminder.title = draft.title reminder.calendar = remindersList(named: draft.list) ?? eventStore.defaultCalendarForNewReminders() reminder.dueDateComponents = Calendar.current.dateComponents( [.year, .month, .day, .hour, .minute], from: draft.due ) reminder.addAlarm(EKAlarm(absoluteDate: draft.due)) try eventStore.save(reminder, commit: true) } func createEvent(_ draft: EventDraft) async throws { try await ensureCalendarAccess() let event = EKEvent(eventStore: eventStore) event.title = draft.title event.startDate = draft.start event.endDate = draft.end event.location = draft.location event.calendar = eventStore.defaultCalendarForNewEvents try eventStore.save(event, span: .thisEvent, commit: true) } // MARK: - Reads /// Output is budgeted: at most `limit` lines, one short line each /// (a tool returning 30 events kills the session — CLAUDE.md §4). func upcomingEvents(days: Int, limit: Int) async throws -> [String] { try await ensureCalendarAccess() let start = Date.now let end = Calendar.current.date(byAdding: .day, value: days, to: start) ?? start let predicate = eventStore.predicateForEvents(withStart: start, end: end, calendars: nil) return eventStore.events(matching: predicate) .sorted { $0.startDate < $1.startDate } .prefix(limit) .map { event in let when = event.startDate.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) return "• \(when) — \(event.title ?? "Sans titre")" } } func upcomingReminders(limit: Int) async throws -> [String] { try await ensureRemindersAccess() let predicate = eventStore.predicateForIncompleteReminders( withDueDateStarting: nil, ending: nil, calendars: nil ) // EKReminder is not Sendable: render to plain strings inside the // fetch callback instead of carrying objects across the continuation. return await withCheckedContinuation { continuation in eventStore.fetchReminders(matching: predicate) { result in let lines = (result ?? []) .prefix(limit) .map { reminder in let due = reminder.dueDateComponents?.date.map { " — " + $0.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) } ?? "" return "• \(reminder.title ?? "Sans titre")\(due)" } continuation.resume(returning: Array(lines)) } } } private func remindersList(named name: String?) -> EKCalendar? { guard let name else { return nil } return eventStore.calendars(for: .reminder) .first { $0.title.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame } } }