Files
Matthias a60a76b797 Add scan flow MVP and local Axiom skill workspace
This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
2026-04-19 21:11:32 +02:00

352 lines
14 KiB
Markdown

---
name: axiom-eventkit
description: Use when working with ANY calendar event, reminder, EventKit permission, or EventKitUI controller. Covers access tiers (no-access, write-only, full), permission migration from pre-iOS 17, store lifecycle, reminder patterns, EventKitUI controller selection, Siri Event Suggestions, virtual conference extensions.
license: MIT
---
# EventKit — Discipline
## Core Philosophy
> "Request the minimum access needed, and only when it's needed."
**Mental model**: EventKit has three access tiers. Most apps need only the first (no access + system UI). Requesting more than you need means more users deny your request, and more code to maintain.
## When to Use This Skill
Use this skill when:
- Adding events or reminders to the user's calendar
- Choosing between EventKitUI, write-only, or full access
- Requesting calendar or reminder permissions
- Fetching, querying, or displaying existing events
- Migrating from pre-iOS 17 permission APIs
- Creating virtual conference extensions
- Implementing Siri Event Suggestions for reservations
- Debugging "access denied" or missing events
Do NOT use this skill for:
- Contacts framework questions (use **contacts**)
- General SwiftUI architecture (use **swiftui-architecture**)
- Background task scheduling (use **background-processing**)
## Related Skills
- **eventkit-ref** — Complete EventKit/EventKitUI API reference
- **contacts** — Contacts framework discipline skill
- **privacy-ux** — General iOS privacy patterns and Permission UX
- **extensions-widgets** — WidgetKit if combining calendar with widgets
- **background-processing** — If scheduling background calendar sync
---
## Access Tier Decision Tree
```dot
digraph access_decision {
rankdir=TB;
"What does your app need?" [shape=diamond];
"Add single events to Calendar?" [shape=diamond];
"Show custom create/edit UI?" [shape=diamond];
"Read existing events/calendars?" [shape=diamond];
"No access + EventKitUI" [shape=box, label="Tier 1: No Access\nPresent EKEventEditViewController\nNo permission prompt needed"];
"No access + Siri Suggestions" [shape=box, label="Tier 1: No Access\nSiri Event Suggestions\nFor reservations only"];
"Write-only access" [shape=box, label="Tier 2: Write-Only\nrequestWriteOnlyAccessToEvents()\nCan save but not read"];
"Full access" [shape=box, label="Tier 3: Full Access\nrequestFullAccessToEvents()\nor requestFullAccessToReminders()"];
"What does your app need?" -> "Add single events to Calendar?" [label="events"];
"What does your app need?" -> "Full access" [label="reminders\n(always full)"];
"Add single events to Calendar?" -> "No access + EventKitUI" [label="yes, one at a time"];
"Add single events to Calendar?" -> "Show custom create/edit UI?" [label="no, batch or silent"];
"Show custom create/edit UI?" -> "Write-only access" [label="yes, or batch save"];
"Show custom create/edit UI?" -> "Read existing events/calendars?" [label="no"];
"Read existing events/calendars?" -> "Full access" [label="yes"];
"Read existing events/calendars?" -> "Write-only access" [label="no"];
"Add single events to Calendar?" -> "No access + Siri Suggestions" [label="reservation-style\n(restaurant, flight, hotel)"];
}
```
**Key rule**: Reminders ALWAYS require full access. There is no write-only tier for reminders.
---
## The Three Access Tiers
### Tier 1: No Access (Preferred)
Present `EKEventEditViewController` — it runs out-of-process on iOS 17+ and requires zero permissions.
```swift
let store = EKEventStore()
let event = EKEvent(eventStore: store)
event.title = "Team Standup"
event.startDate = startDate
event.endDate = Calendar.current.date(byAdding: .hour, value: 1, to: startDate) ?? startDate
event.timeZone = TimeZone(identifier: "America/Los_Angeles")
event.location = "Conference Room A"
let editVC = EKEventEditViewController()
editVC.event = event
editVC.eventStore = store
editVC.editViewDelegate = self
present(editVC, animated: true)
```
**Why this is best**: No permission prompt. No denial risk. System handles Calendar selection and save. Works on iOS 4+.
For reservations (restaurant, flight, hotel, event tickets), use **Siri Event Suggestions** instead — events appear in Calendar inbox without any permission. See the eventkit-ref skill for the INReservation donation pattern.
### Tier 2: Write-Only Access (iOS 17+)
Use only when you need: custom editing UI, batch saves, or silent event creation.
```swift
let store = EKEventStore()
guard try await store.requestWriteOnlyAccessToEvents() else {
// User denied handle gracefully
return
}
let event = EKEvent(eventStore: store)
event.calendar = store.defaultCalendarForNewEvents // REQUIRED for write-only
event.title = "Recurring Standup"
event.startDate = startDate
event.endDate = endDate
try store.save(event, span: .thisEvent)
```
**Write-only constraints**:
- Returns a single virtual calendar, not the user's real calendars
- Event queries return empty results
- System chooses destination calendar for created events
- Cannot read events back, even ones your app created
**Info.plist required**: `NSCalendarsWriteOnlyAccessUsageDescription`
### Tier 3: Full Access
Use only when your app's core feature requires reading, modifying, or deleting existing events.
```swift
let store = EKEventStore()
guard try await store.requestFullAccessToEvents() else { return }
// Now you can fetch events
let interval = Calendar.current.dateInterval(of: .month, for: Date())!
let predicate = store.predicateForEvents(withStart: interval.start, end: interval.end, calendars: nil)
let events = store.events(matching: predicate)
.sorted { $0.compareStartDate(with: $1) == .orderedAscending }
```
**Info.plist required**: `NSCalendarsFullAccessUsageDescription`
For reminders:
```swift
guard try await store.requestFullAccessToReminders() else { return }
```
**Info.plist required**: `NSRemindersFullAccessUsageDescription`
---
## Anti-Patterns
| Pattern | Time Cost | Why It's Wrong | Fix |
|---------|-----------|----------------|-----|
| Requesting full access for "add to calendar" | 1-2 sprint days recovering denied users | Full access prompts are denied 30%+ of the time — users distrust reading ALL calendar data | Use EventKitUI or write-only |
| Missing Info.plist key on iOS 17+ | 1-2 hours debugging | Automatic silent denial, no crash, no error, no prompt | Add the correct usage description key |
| Missing Info.plist key on iOS 16 and below | Immediate crash | App crashes on permission request | Add `NSCalendarsUsageDescription` |
| Calling deprecated `requestAccess(to:)` on iOS 17 | Throws error | The old API throws, does not prompt | Use `requestFullAccessToEvents()` or `requestWriteOnlyAccessToEvents()` |
| Creating multiple EKEventStore instances | Stale data bugs | Objects from one store cannot be used with another | Create one store, reuse it |
| Using `Date` math instead of `DateComponents` for durations | DST bugs | Adding 3600 seconds doesn't always equal 1 hour | Use `Calendar.current.date(byAdding:)` |
| Not sorting `events(matching:)` results | Wrong display order | Results are NOT chronologically ordered | Sort with `compareStartDate(with:)` |
| Setting `dueDateComponents` with `Date` instead of `DateComponents` | Silent failure | Reminders use `DateComponents`, not `Date` | Convert via `Calendar.current.dateComponents(...)` |
| Not registering for `EKEventStoreChanged` notification | Stale UI | External Calendar changes are invisible | Register and refetch on notification |
| Ignoring `EKSpan` on recurring events | Modifying all occurrences | `.thisEvent` vs `.futureEvents` controls scope | Always choose explicitly |
---
## Reminder Patterns
Reminders ALWAYS require `requestFullAccessToReminders()`.
### Creating a Reminder
```swift
let reminder = EKReminder(eventStore: store)
reminder.title = "Review PR"
reminder.calendar = store.defaultCalendarForNewReminders() // Required
// Due dates use DateComponents, NOT Date
if let dueDate = dueDate {
reminder.dueDateComponents = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute], from: dueDate
)
}
reminder.priority = EKReminderPriority.medium.rawValue
try store.save(reminder, commit: true)
```
### Fetching Reminders (Async)
Unlike events, reminder fetches are asynchronous:
```swift
let predicate = store.predicateForReminders(in: nil) // nil = all calendars
let reminders = try await withCheckedThrowingContinuation { continuation in
store.fetchReminders(matching: predicate) { reminders in
if let reminders {
continuation.resume(returning: reminders)
} else {
continuation.resume(throwing: TodayError.failedReadingReminders)
}
}
}
```
### Creating Reminder Lists
Reminder lists are `EKCalendar` objects filtered by entity type:
```swift
let newList = EKCalendar(for: .reminder, eventStore: store)
newList.title = "Sprint Tasks"
// Source selection matters prefer .local or .calDAV
guard let source = store.sources.first(where: {
$0.sourceType == .local || $0.sourceType == .calDAV
}) ?? store.defaultCalendarForNewReminders()?.source else {
throw EventKitError.noValidSource
}
newList.source = source
try store.saveCalendar(newList, commit: true)
```
---
## Store Lifecycle
### Singleton Pattern
Create one `EKEventStore` and reuse it. Objects from one store instance cannot be used with another.
### Change Notifications
```swift
NotificationCenter.default.addObserver(
self, selector: #selector(storeChanged),
name: .EKEventStoreChanged, object: store
)
@objc func storeChanged(_ notification: Notification) {
// Refetch your current date range
// Individual objects: call refresh() if false, refetch
}
```
### Batch Operations
```swift
// Pass commit: false for batch, then commit once
try store.save(event1, span: .thisEvent, commit: false)
try store.save(event2, span: .thisEvent, commit: false)
try store.commit() // Atomic save
// On failure: store.reset() to rollback
```
---
## Migration from Pre-iOS 17
| Before iOS 17 | iOS 17+ Replacement |
|----------------|---------------------|
| `requestAccess(to: .event)` | `requestFullAccessToEvents()` or `requestWriteOnlyAccessToEvents()` |
| `requestAccess(to: .reminder)` | `requestFullAccessToReminders()` |
| `NSCalendarsUsageDescription` | `NSCalendarsFullAccessUsageDescription` or `NSCalendarsWriteOnlyAccessUsageDescription` |
| `NSRemindersUsageDescription` | `NSRemindersFullAccessUsageDescription` |
| `authorizationStatus == .authorized` | Check for `.fullAccess` or `.writeOnly` |
**Runtime compatibility**:
```swift
if #available(iOS 17.0, *) {
granted = try await store.requestFullAccessToEvents()
} else {
granted = try await store.requestAccess(to: .event)
}
```
**Keep old Info.plist keys** alongside new ones to support iOS 16 and below.
**Gotcha**: Apps built with older Xcode SDKs map both `.writeOnly` and `.fullAccess` to `.authorized`. This means an app linked against an old SDK may fail to fetch events even after users granted full access — because the app sees `.authorized` but the system gave `.writeOnly`.
---
## EventKitUI Decision Guide
| Controller | Purpose | Permission Required |
|------------|---------|---------------------|
| `EKEventEditViewController` | Create/edit events | None (iOS 17+ out-of-process) |
| `EKEventViewController` | Display event details | Full access |
| `EKCalendarChooser` | Calendar selection | Write-only or full |
**Gotcha**: `EKEventEditViewController` inherits from `UINavigationController`, not `UIViewController`. Do NOT embed it inside another navigation controller.
**Gotcha**: `EKEventViewController` inherits from `UIViewController` and CAN be pushed onto a navigation stack.
**Gotcha**: Under write-only access, `EKCalendarChooser` ignores `displayStyle` and always shows writable calendars only.
---
## Pressure Scenarios
### Scenario 1: "Just request full access, we might need it later"
**Pressure**: Product manager asks for full access "just in case."
**Why resist**: Full access prompts are denied 30%+ of the time. Write-only or EventKitUI gets you event creation with near-zero denials. You can always upgrade later if a reading feature is added.
**Response**: "Full access shows a scary prompt about reading ALL calendar data. For adding events, EventKitUI needs no prompt at all. Let's start there and upgrade if we ship a feature that reads events."
### Scenario 2: "The deprecated API still works, we'll migrate later"
**Pressure**: Deadline pressure to skip migration from `requestAccess(to:)`.
**Why resist**: On iOS 17, calling `requestAccess(to: .event)` throws an error — no prompt, no access, broken feature. Users on iOS 17+ get a silent failure.
**Response**: "The deprecated API throws on iOS 17. It's not 'deprecated but works' — it's broken. The fix is a 3-line `#available` check."
### Scenario 3: "Just create a new EKEventStore for each screen"
**Pressure**: Different view controllers each create their own store for isolation.
**Why resist**: Objects from one store cannot be used with another. Events fetched from store A cannot be saved by store B. Change notifications only fire on the store that's registered.
**Response**: "EventKit requires a single shared store. Objects are bound to the store that created them. Create one and inject it."
---
## Error Handling
Key `EKErrorDomain` codes to handle:
| Code | Meaning | Fix |
|------|---------|-----|
| `eventStoreNotAuthorized` | No permission | Check and request access first |
| `noCalendar` | Calendar not set on event | Set `event.calendar` before save |
| `noStartDate` / `noEndDate` | Missing dates | Set both before save |
| `datesInverted` | End before start | Validate date order |
| `calendarReadOnly` / `calendarIsImmutable` | Can't write to this calendar | Use `allowsContentModifications` check |
| `objectBelongsToDifferentStore` | Cross-store usage | Use single store instance |
| `recurringReminderRequiresDueDate` | Recurring reminder missing due date | Set `dueDateComponents` |
---
## Resources
**WWDC**: 2023-10052, 2020-10197
**Docs**: /eventkit, /eventkitui, /technotes/tn3152, /technotes/tn3153
**Skills**: eventkit-ref, contacts, privacy-ux, extensions-widgets