# EventKit Patterns

Extended recipes for the `eventkit-calendar` skill.

## A SwiftUI week view

Reading events needs full access. Write-only access cannot read anything,
including events the app saved itself. If the app still supports iOS 16 or
earlier, put the iOS 17 request methods behind availability checks.

```swift
import SwiftUI
import EventKit

@MainActor @Observable
final class WeekAgenda {
    private(set) var items: [EKEvent] = []
    private(set) var status = EKEventStore.authorizationStatus(for: .event)

    @ObservationIgnored private let store = EKEventStore()
    @ObservationIgnored private var changeToken: (any NSObjectProtocol)?

    func connect() async {
        let granted = (try? await store.requestFullAccessToEvents()) ?? false
        status = granted ? .fullAccess : .denied
        guard granted else { return }
        reload()
        let center = NotificationCenter.default
        changeToken = center.addObserver(forName: .EKEventStoreChanged, object: store, queue: .main) { [weak self] _ in
            Task { @MainActor in self?.reload() }
        }
    }

    func reload() {
        let monday = Calendar.current.startOfDay(for: .now)
        let week = store.predicateForEvents(
            withStart: monday,
            end: monday.addingTimeInterval(7 * 86_400),
            calendars: nil
        )
        items = store.events(matching: week).sorted { $0.startDate < $1.startDate }
    }
}

struct WeekAgendaView: View {
    @State private var agenda = WeekAgenda()

    var body: some View {
        List(agenda.items, id: \.eventIdentifier) { event in
            LabeledContent {
                Text(event.startDate, style: .date)
            } label: {
                Text(event.title)
            }
        }
        .overlay {
            if agenda.items.isEmpty {
                ContentUnavailableView("Nothing This Week", systemImage: "calendar")
            }
        }
        .task { await agenda.connect() }
    }
}
```

## Queries

Keep these limits in mind:

- A predicate covers at most four years.
- Fetches are synchronous and see only committed data, so commit a batch
  before querying it.
- Large reads block the caller; run them away from the main actor.

### One calendar, one interval

```swift
func events(in calendar: EKCalendar, during span: DateInterval) -> [EKEvent] {
    let query = store.predicateForEvents(withStart: span.start, end: span.end, calendars: [calendar])
    return store.events(matching: query)
}
```

### Completed reminders

As in the main skill, only `Sendable` values leave the callback; here that is
the identifiers.

```swift
func finishedReminders(since start: Date) async -> [String] {
    let query = store.predicateForCompletedReminders(
        withCompletionDateStarting: start, ending: .now, calendars: nil
    )
    return await withCheckedContinuation { resume in
        store.fetchReminders(matching: query) { found in
            resume.resume(returning: (found ?? []).map(\.calendarItemIdentifier))
        }
    }
}
```

### Walking a long range

```swift
var firstClash: EKEvent?
store.enumerateEvents(matching: query) { event, stop in
    if event.availability == .busy, event.startDate < proposedEnd, event.endDate > proposedStart {
        firstClash = event
        stop.pointee = true
    }
}
```

`enumerateEvents` hands over one event at a time instead of building an array,
and setting `stop.pointee = true` ends the walk early.

## Batches

```swift
func importSessions(_ sessions: [ConferenceSession], into calendar: EKCalendar) throws {
    for session in sessions {
        let event = EKEvent(eventStore: store)
        event.title = session.title
        event.startDate = session.start
        event.endDate = session.end
        event.calendar = calendar
        try store.save(event, span: .thisEvent, commit: false)
    }
    try store.commit()
}

func cancelAll(_ doomed: [EKEvent]) throws {
    for item in doomed {
        try store.remove(item, span: .thisEvent, commit: false)
    }
    try store.commit()
}
```

One `commit()` at the end writes the whole batch. `store.reset()` throws away
everything not yet committed.

## Managing calendars

```swift
func makeTripCalendar() throws -> EKCalendar {
    let trips = EKCalendar(for: .event, eventStore: store)
    trips.title = "Trips"
    trips.cgColor = UIColor.systemTeal.cgColor
    trips.source = store.sources.first { $0.sourceType == .local }
        ?? store.defaultCalendarForNewEvents?.source
    try store.saveCalendar(trips, commit: true)
    return trips
}
```

A local source is preferred; otherwise the new calendar joins the default
calendar's source. List calendars with `store.calendars(for: .event)` or
`.reminder`, and keep only those with `allowsContentModifications` when you
need a writable one.

## Reminder recipes

### Location-triggered reminder

```swift
func remindOnArrival(at place: CLLocation, named title: String) throws {
    let access = EKEventStore.authorizationStatus(for: .reminder)
    guard access == .fullAccess else { throw PlannerError.remindersNotAllowed }
    guard let list = store.defaultCalendarForNewReminders() else {
        throw PlannerError.noReminderList
    }
    let reminder = EKReminder(eventStore: store)
    reminder.title = title
    reminder.calendar = list

    let spot = EKStructuredLocation(title: "Garden Centre")
    spot.geoLocation = place
    spot.radius = 150

    let trigger = EKAlarm(relativeOffset: 0)
    trigger.structuredLocation = spot
    trigger.proximity = .enter
    reminder.addAlarm(trigger)

    try store.save(reminder, commit: true)
}
```

`radius` is in meters. Use `.leave` instead of `.enter` for departure alerts.
The reminder needs a title and a calendar before it is saved. Coordinates from your own data need no
location permission. Only when `place` comes from where the person is right
now does the app add `NSLocationWhenInUseUsageDescription` and ask Core
Location.

### Priority

| `priority` | Meaning |
|------------|---------|
| 0 | None |
| 1 to 4 | High |
| 5 | Medium |
| 6 to 9 | Low |

## The event editor in SwiftUI

```swift
import EventKitUI

struct EventEditorSheet: UIViewControllerRepresentable {
    let store: EKEventStore
    @Binding var isPresented: Bool

    func makeCoordinator() -> Coordinator { Coordinator(isPresented: $isPresented) }

    func makeUIViewController(context: Context) -> EKEventEditViewController {
        let sheet = EKEventEditViewController()
        sheet.editViewDelegate = context.coordinator
        sheet.eventStore = store
        return sheet
    }

    func updateUIViewController(_ sheet: EKEventEditViewController, context: Context) {}

    final class Coordinator: NSObject, EKEventEditViewDelegate {
        @Binding var isPresented: Bool

        init(isPresented: Binding<Bool>) { _isPresented = isPresented }

        func eventEditViewController(_ sheet: EKEventEditViewController,
                                     didCompleteWith outcome: EKEventEditViewAction) {
            isPresented = false
        }
    }
}

struct AddToCalendarButton: View {
    let store: EKEventStore
    @State private var editing = false

    var body: some View {
        Button("Add to Calendar", systemImage: "calendar.badge.plus") { editing = true }
            .sheet(isPresented: $editing) {
                EventEditorSheet(store: store, isPresented: $editing)
            }
    }
}
```

From iOS 17 this needs no calendar permission at all: the editor lives in
another process. The app does not learn what was saved; it can see
the result only with full access and a fresh fetch.

## Change notifications, old and new

`.EKEventStoreChanged` works on every supported version. On iOS 26 the typed
message can be used through Foundation's message-based observer API:

```swift
if #available(iOS 26, *) {
    storeObservation = NotificationCenter.default.addObserver(of: store, for: .changed) { _ in
        agenda.reload()
    }
} else {
    let center = NotificationCenter.default
    storeObservation = center.addObserver(forName: .EKEventStoreChanged, object: store, queue: nil) { _ in
        Task { @MainActor in agenda.reload() }
    }
}
```

Keep the returned token alive for as long as updates are wanted. The two APIs
return different token types, so `storeObservation` is declared as `Any?`.
