diff --git a/Sources/Shift/Shift.swift b/Sources/Shift/Shift.swift index 513b2d5..5a4e9c9 100644 --- a/Sources/Shift/Shift.swift +++ b/Sources/Shift/Shift.swift @@ -1,29 +1,17 @@ -/** - * Shift - * Copyright (c) Vinh Nguyen 2021 - * MIT license, see LICENSE file for details - */ - import Foundation import SwiftUI import EventKit -import Algorithms - -typealias RequestAccessCompletion = ((Bool, Error?) -> Void) -/// ShiftError definition public enum ShiftError: Error, LocalizedError { case mapFromError(Error) case unableToAccessCalendar - case eventAuthorizationStatus(EKAuthorizationStatus? = nil) - case invalidEvent + case failedToAuthorizeEventPersmissson(EKAuthorizationStatus? = nil) var localizedDescription: String { switch self { - case .invalidEvent: return "Invalid event" case .unableToAccessCalendar: return "Unable to access celendar" case let .mapFromError(error): return error.localizedDescription - case let .eventAuthorizationStatus(status): + case let .failedToAuthorizeEventPersmissson(status): if let status = status { return "Failed to authorize event persmissson, status: \(status)" } else { @@ -38,10 +26,8 @@ public final class Shift: ObservableObject { // MARK: - Properties - /// Streams of EKEvent instances @Published public var events = [EKEvent]() - /// The calendar’s title. public static var appName: String? /// Event store: An object that accesses the user’s calendar and reminder events and supports the scheduling of new events. @@ -54,11 +40,8 @@ public final class Shift: ObservableObject { // MARK: Lifecycle - /// Shared `Shift` singleton public static let shared = Shift() - /// Configuration point - /// - Parameter appName: the calendar's title public static func configureWithAppName(_ appName: String) { self.appName = appName } @@ -68,14 +51,36 @@ public final class Shift: ObservableObject { // MARK: - Flow /// Request event store authorization - /// - Returns: EKAuthorizationStatus enum - public func requestEventStoreAuthorization() async throws -> EKAuthorizationStatus { - let granted = try await requestCalendarAccess() - if granted { - return EKEventStore.authorizationStatus(for: .event) - } - else { - throw ShiftError.unableToAccessCalendar + /// - Parameter completion: completion handler with an EKAuthorizationStatus enum + public func requestEventStoreAuthorization(completion: ((Result) -> Void)?) { + let status = EKEventStore.authorizationStatus(for: .event) + + switch status { + case .authorized: + DispatchQueue.main.async { completion?(.success(status)) } + + case .denied, + .restricted: + DispatchQueue.main.async { completion?(.failure(ShiftError.failedToAuthorizeEventPersmissson(status))) } + + case .notDetermined: + requestCalendarAccess { result in + switch result { + case let .success(granted): + if granted { + DispatchQueue.main.async { completion?(.success(.authorized)) } + } + else { + DispatchQueue.main.async { completion?(.failure(ShiftError.unableToAccessCalendar)) } + } + + case let .failure(error): + DispatchQueue.main.async { completion?(.failure(ShiftError.mapFromError(error))) } + } + } + + @unknown default: + DispatchQueue.main.async { completion?(.failure(ShiftError.failedToAuthorizeEventPersmissson(status))) } } } @@ -83,12 +88,10 @@ public final class Shift: ObservableObject { /// Create an event /// - Parameters: - /// - title: title of the event - /// - startDate: event's start date - /// - endDate: event's end date - /// - span: event's span - /// - isAllDay: is all day event - /// - Returns: created event + /// - title: event title + /// - startDate: event start date + /// - endDate: event end date + /// - completion: completion handler #if os(iOS) || os(macOS) public func createEvent( _ title: String, @@ -96,31 +99,62 @@ public final class Shift: ObservableObject { endDate: Date?, span: EKSpan = .thisEvent, isAllDay: Bool = false, - calendar: EKCalendar? = nil - ) async throws -> EKEvent { - var theCalendar: EKCalendar - if let calendar = calendar { - theCalendar = calendar - } else { - theCalendar = try await accessCalendar() - } + completion: ((Result) -> Void)? + ) { + requestEventStoreAuthorization { [weak self] result in + switch result { + case let .success(status): + guard let self = self else { return } + guard status == .authorized else { return } + + self.accessCalendar { [weak self] calendarResult in + guard let self = self else { return } + + switch calendarResult { + case let .success(calendar): + self.eventStore.createEvent(title: title, startDate: startDate, endDate: endDate, calendar: calendar, span: span, isAllDay: isAllDay, completion: completion) + + case let .failure(error): + DispatchQueue.main.async { completion?(.failure(error)) } + } + } - let createdEvent = try await self.eventStore.createEvent(title: title, startDate: startDate, endDate: endDate, calendar: theCalendar, span: span, isAllDay: isAllDay) - return createdEvent + case let .failure(error): + DispatchQueue.main.async { completion?(.failure(error)) } + } + } } #endif /// Delete an event /// - Parameters: /// - identifier: event identifier - /// - span: even't span + /// - span: An object that indicates whether modifications should apply to a single event or all future events of a recurring event. + /// - completion: completion handler #if os(iOS) || os(macOS) - public func deleteEvent( - identifier: String, - span: EKSpan = .thisEvent - ) async throws { - try await accessCalendar() - try self.eventStore.deleteEvent(identifier: identifier, span: span) + public func deleteEvent(identifier: String, span: EKSpan = .thisEvent, completion: ((Result) -> Void)?) { + requestEventStoreAuthorization { [weak self] result in + switch result { + case let .success(status): + guard let self = self else { return } + guard status == .authorized else { return } + + self.accessCalendar { [weak self] calendarResult in + guard let self = self else { return } + + switch calendarResult { + case .success: + self.eventStore.deleteEvent(identifier: identifier, span: span, completion: completion) + + case let .failure(error): + DispatchQueue.main.async { completion?(.failure(error)) } + } + } + + case let .failure(error): + DispatchQueue.main.async { completion?(.failure(error)) } + } + } } #endif @@ -129,13 +163,9 @@ public final class Shift: ObservableObject { /// Fetch events for today /// - Parameter completion: completion handler /// - Parameter filterCalendarIDs: filterable Calendar IDs - @discardableResult - public func fetchEventsForToday( - filterCalendarIDs: [String] = [], - calendar: Calendar = .autoupdatingCurrent - ) async throws -> [EKEvent] { + public func fetchEventsForToday(filterCalendarIDs: [String] = [], completion: ((Result<[EKEvent], ShiftError>) -> Void)? = nil) { let today = Date() - return try await fetchEvents(startDate: today.startOfDay(calendar: calendar), endDate: today.endOfDay(calendar: calendar), filterCalendarIDs: filterCalendarIDs) + fetchEvents(startDate: today.startOfDay, endDate: today.endOfDay, filterCalendarIDs: filterCalendarIDs, completion: completion) } /// Fetch events for a specific day @@ -143,14 +173,8 @@ public final class Shift: ObservableObject { /// - date: day to fetch events from /// - completion: completion handler /// - filterCalendarIDs: filterable Calendar IDs - /// Returns: events - @discardableResult - public func fetchEvents( - for date: Date, - filterCalendarIDs: [String] = [], - calendar: Calendar = .autoupdatingCurrent - ) async throws -> [EKEvent] { - try await fetchEvents(startDate: date.startOfDay(calendar: calendar), endDate: date.endOfDay(calendar: calendar), filterCalendarIDs: filterCalendarIDs) + public func fetchEvents(for date: Date, filterCalendarIDs: [String] = [], completion: ((Result<[EKEvent], ShiftError>) -> Void)? = nil) { + fetchEvents(startDate: date.startOfDay, endDate: date.endOfDay, filterCalendarIDs: filterCalendarIDs, completion: completion) } /// Fetch events for a specific day @@ -159,14 +183,8 @@ public final class Shift: ObservableObject { /// - completion: completion handler /// - startDate: event start date /// - filterCalendarIDs: filterable Calendar IDs - /// Returns: events - @discardableResult - public func fetchEventsRangeUntilEndOfDay( - from startDate: Date, - filterCalendarIDs: [String] = [], - calendar: Calendar = .autoupdatingCurrent - ) async throws -> [EKEvent] { - try await fetchEvents(startDate: startDate, endDate: startDate.endOfDay(calendar: calendar), filterCalendarIDs: filterCalendarIDs) + public func fetchEventsRangeUntilEndOfDay(from startDate: Date, filterCalendarIDs: [String] = [], completion: ((Result<[EKEvent], ShiftError>) -> Void)? = nil) { + fetchEvents(startDate: startDate, endDate: startDate.endOfDay, filterCalendarIDs: filterCalendarIDs, completion: completion) } /// Fetch events from date range @@ -175,90 +193,105 @@ public final class Shift: ObservableObject { /// - endDate: end date range /// - completion: completion handler /// - filterCalendarIDs: filterable Calendar IDs - /// Returns: events - @discardableResult - public func fetchEvents( - startDate: Date, - endDate: Date, - filterCalendarIDs: [String] = [] - ) async throws -> [EKEvent] { - let authorization = try await requestEventStoreAuthorization() - guard authorization == .authorized else { - throw ShiftError.eventAuthorizationStatus(nil) - } - - let calendars = self.eventStore.calendars(for: .event).filter { calendar in - if filterCalendarIDs.isEmpty { return true } - return filterCalendarIDs.contains(calendar.calendarIdentifier) - } - - let predicate = self.eventStore.predicateForEvents(withStart: startDate, end: endDate, calendars: calendars) - let events = self.eventStore - .events(matching: predicate) - .uniqued(on: \.eventIdentifier) // filter duplicated events + public func fetchEvents(startDate: Date, endDate: Date, filterCalendarIDs: [String] = [], completion: ((Result<[EKEvent], ShiftError>) -> Void)? = nil) { + requestEventStoreAuthorization { [weak self] result in + switch result { + case let .success(status): + guard let self = self else { return } + guard status == .authorized else { return } + + let calendars = self.eventStore + .calendars(for: .event) + .filter { calendar in + if filterCalendarIDs.isEmpty { return true } + return filterCalendarIDs.contains(calendar.calendarIdentifier) + } + + let predicate = self.eventStore.predicateForEvents(withStart: startDate, end: endDate, calendars: calendars) + let events = self.eventStore.events(matching: predicate) + DispatchQueue.main.async { + self.events = events + completion?(.success(events)) + } - // MainActor is a type that runs code on main thread. - await MainActor.run { - self.events = events + case let .failure(error): + DispatchQueue.main.async { + completion?(.failure(error)) + } + } } - - return events } // MARK: Private /// Request access to calendar - /// - Returns: calendar object - @discardableResult - private func accessCalendar() async throws -> EKCalendar { - let authorization = try await requestEventStoreAuthorization() - - guard authorization == .authorized else { - throw ShiftError.eventAuthorizationStatus(nil) - } + /// - Parameter completion: calendar object + private func accessCalendar(completion: ((Result) -> Void)?) { + requestEventStoreAuthorization { [weak self] result in + switch result { + case let .success(status): + guard let self = self else { return } + guard status == .authorized else { return } + guard let calendar = self.eventStore.calendarForApp() else { return } + + DispatchQueue.main.async { + completion?(.success(calendar)) + } - guard let calendar = eventStore.calendarForApp() else { - throw ShiftError.unableToAccessCalendar + case let .failure(error): + DispatchQueue.main.async { + completion?(.failure(error)) + } + } } - - return calendar } - - private func requestCalendarAccess() async throws -> Bool { - try await withCheckedThrowingContinuation { [weak self] continuation in - guard let self else { return } - let completion: RequestAccessCompletion = { granted, error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: granted) + /// Prompt the user for access to their Calendar + /// - Parameter onAuthorized: on authorized + private func requestCalendarAccess(completion: ((Result) -> Void)?) { + if #available(iOS 17.0, *) { + eventStore.requestFullAccessToEvents { granted, error in + if granted { + DispatchQueue.main.async { + completion?(.success(granted)) + } + } + else if let error = error { + DispatchQueue.main.async { completion?(.failure(error)) } + } + else { + DispatchQueue.main.async { completion?(.failure(ShiftError.unableToAccessCalendar)) } } } - - if #available(iOS 17.0, *) { - self.eventStore.requestFullAccessToEvents(completion: completion) - } else { - self.eventStore.requestAccess(to: .event, completion: completion) + } else { + eventStore.requestAccess(to: .event) { granted, error in + if granted { + DispatchQueue.main.async { + completion?(.success(granted)) + } + } + else if let error = error { + DispatchQueue.main.async { completion?(.failure(error)) } + } + else { + DispatchQueue.main.async { completion?(.failure(ShiftError.unableToAccessCalendar)) } + } } } } - } extension EKEventStore { - // MARK: - CRUD /// Create an event /// - Parameters: - /// - title: title of the event - /// - startDate: event's start date - /// - endDate: event's end date - /// - calendar: calendar instance - /// - span: event's span - /// - isAllDay: is all day event - /// - Returns: created event + /// - title: event title + /// - startDate: event startDate + /// - endDate: event endDate + /// - calendar: event calendar + /// - span: event span + /// - completion: event completion handler that returns an event #if os(iOS) || os(macOS) public func createEvent( title: String, @@ -266,33 +299,51 @@ extension EKEventStore { endDate: Date?, calendar: EKCalendar, span: EKSpan = .thisEvent, - isAllDay: Bool = false - ) async throws -> EKEvent { + isAllDay: Bool = false, + completion: ((Result) -> Void)? + ) { let event = EKEvent(eventStore: self) event.calendar = calendar event.title = title event.isAllDay = isAllDay event.startDate = startDate event.endDate = endDate - try save(event, span: span, commit: true) - return event + + do { + try save(event, span: span, commit: true) + DispatchQueue.main.async { completion?(.success(event)) } + } catch { + DispatchQueue.main.async { + completion?(.failure(error)) + } + } } #endif /// Delete event /// - Parameters: /// - identifier: event identifier - /// - span: event's span + /// - span: event span + /// - completion: event completion handler that returns an event #if os(iOS) || os(macOS) public func deleteEvent( identifier: String, - span: EKSpan = .thisEvent - ) throws { - guard let event = fetchEvent(identifier: identifier) else { - throw ShiftError.invalidEvent - } + span: EKSpan = .thisEvent, + completion: ((Result) -> Void)? = nil + ) { + guard let event = fetchEvent(identifier: identifier) else { return } + + do { + try remove(event, span: span, commit: true) - try remove(event, span: span, commit: true) + DispatchQueue.main.async { + completion?(.success(())) + } + } catch { + DispatchQueue.main.async { + completion?(.failure(ShiftError.mapFromError(error))) + } + } } #endif @@ -337,15 +388,15 @@ extension EKEventStore { } extension Date { - func startOfDay(calendar: Calendar = .autoupdatingCurrent) -> Date { - calendar.startOfDay(for: self) + var startOfDay: Date { + Calendar.current.startOfDay(for: self) } - func endOfDay(calendar: Calendar = .autoupdatingCurrent) -> Date { + var endOfDay: Date { var components = DateComponents() components.day = 1 components.second = -1 // swiftlint:disable:next force_unwrapping - return calendar.date(byAdding: components, to: startOfDay(calendar: calendar))! + return Calendar.current.date(byAdding: components, to: startOfDay)! } }