443 lines
14 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|