remindctl/Sources/RemindCore/EventKitStore.swift
Peter Steinberger 85a589366e
feat: add location reminder triggers
Co-authored-by: Octavio Froid <froid@bohm.com>
2026-05-04 05:58:14 +01:00

443 lines
14 KiB
Swift

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<Calendar.Component> =
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
}
}
}