mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 23:24:32 +00:00
344 lines
11 KiB
Swift
344 lines
11 KiB
Swift
//
|
|
// CalendarServiceProvider.swift
|
|
// Calendr
|
|
//
|
|
// Created by Paker on 31/12/20.
|
|
// Original source: Original source: https://github.com/pakerwreah/Calendr
|
|
// Modified by Alexander on 08/06/25
|
|
//
|
|
|
|
import Foundation
|
|
import EventKit
|
|
|
|
protocol CalendarServiceProviding {
|
|
func requestAccess() async -> Bool
|
|
func requestAccess(to type: EKEntityType) async -> Bool
|
|
func calendars() async -> [CalendarModel]
|
|
func events(from start: Date, to end: Date, calendars: [String]) async -> [EventModel]
|
|
func setReminderCompleted(reminderID: String, completed: Bool) async
|
|
}
|
|
|
|
class CalendarService: CalendarServiceProviding {
|
|
private let store = EKEventStore()
|
|
|
|
@MainActor
|
|
func requestAccess() async -> Bool {
|
|
let eventsAccess = await requestAccess(to: .event)
|
|
let remindersAccess = await requestAccess(to: .reminder)
|
|
return eventsAccess || remindersAccess
|
|
}
|
|
|
|
@MainActor
|
|
func requestAccess(to type: EKEntityType) async -> Bool {
|
|
do {
|
|
return try await performAccessRequest(for: type)
|
|
} catch {
|
|
print("Calendar access error: \(error)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func performAccessRequest(for type: EKEntityType) async throws -> Bool {
|
|
switch type {
|
|
case .event:
|
|
return try await store.requestFullAccessToEvents()
|
|
case .reminder:
|
|
return try await store.requestFullAccessToReminders()
|
|
@unknown default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func hasAccess(to entityType: EKEntityType) -> Bool {
|
|
let status = EKEventStore.authorizationStatus(for: entityType)
|
|
return status == .fullAccess
|
|
}
|
|
|
|
func calendars() async -> [CalendarModel] {
|
|
var calendars: [EKCalendar] = []
|
|
|
|
for type in [EKEntityType.event, .reminder] where hasAccess(to: type) {
|
|
calendars.append(contentsOf: store.calendars(for: type))
|
|
}
|
|
|
|
return calendars.map { CalendarModel(from: $0) }
|
|
}
|
|
|
|
func events(from start: Date, to end: Date, calendars ids: [String]) async -> [EventModel] {
|
|
let allCalendars = await self.calendars()
|
|
let filteredCalendars = allCalendars.filter { ids.isEmpty || ids.contains($0.id) }
|
|
let ekCalendars = filteredCalendars.compactMap { calendarModel in
|
|
store.calendars(for: .event).first { $0.calendarIdentifier == calendarModel.id } ??
|
|
store.calendars(for: .reminder).first { $0.calendarIdentifier == calendarModel.id }
|
|
}
|
|
|
|
var events: [EventModel] = []
|
|
|
|
// Fetch regular events
|
|
if hasAccess(to: .event) {
|
|
let eventCalendars = ekCalendars.filter { store.calendars(for: .event).contains($0) }
|
|
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: eventCalendars)
|
|
let ekEvents = store.events(matching: predicate)
|
|
events.append(contentsOf: ekEvents.compactMap { EventModel(from: $0) })
|
|
}
|
|
|
|
// Fetch reminders
|
|
if hasAccess(to: .reminder) {
|
|
let reminderCalendars = ekCalendars.filter { store.calendars(for: .reminder).contains($0) }
|
|
events.append(contentsOf: await fetchReminders(from: start, to: end, calendars: reminderCalendars))
|
|
}
|
|
|
|
return events.sorted { $0.start < $1.start }
|
|
}
|
|
|
|
private func fetchReminders(from start: Date, to end: Date, calendars: [EKCalendar]) async -> [EventModel] {
|
|
guard !calendars.isEmpty else { return [] }
|
|
|
|
return await withCheckedContinuation { continuation in
|
|
let predicate = store.predicateForReminders(in: calendars)
|
|
store.fetchReminders(matching: predicate) { reminders in
|
|
guard let reminders else {
|
|
continuation.resume(returning: [])
|
|
return
|
|
}
|
|
|
|
let filtered = reminders.compactMap { reminder -> EventModel? in
|
|
guard let dueDate = reminder.dueDateComponents?.date,
|
|
dueDate >= start,
|
|
dueDate <= end else {
|
|
return nil
|
|
}
|
|
return EventModel(from: reminder)
|
|
}
|
|
|
|
continuation.resume(returning: filtered)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func setReminderCompleted(reminderID: String, completed: Bool) async {
|
|
guard let reminder = store.calendarItem(withIdentifier: reminderID) as? EKReminder else { return }
|
|
reminder.isCompleted = completed
|
|
do {
|
|
try store.save(reminder, commit: true)
|
|
} catch {
|
|
print("Failed to update reminder completion: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Model Extensions
|
|
|
|
extension CalendarModel {
|
|
init(from calendar: EKCalendar) {
|
|
self.init(
|
|
id: calendar.calendarIdentifier,
|
|
title: calendar.title,
|
|
color: calendar.color,
|
|
isSubscribed: calendar.isSubscribed || calendar.isDelegate,
|
|
isReminder: calendar.allowedEntityTypes.contains(.reminder)
|
|
)
|
|
}
|
|
}
|
|
|
|
extension EventModel {
|
|
init?(from event: EKEvent) {
|
|
guard let calendar = event.calendar else { return nil }
|
|
|
|
self.init(
|
|
id: event.calendarItemIdentifier,
|
|
start: event.startDate,
|
|
end: event.endDate,
|
|
title: event.title ?? "",
|
|
location: event.location,
|
|
notes: event.notes,
|
|
url: event.url,
|
|
isAllDay: event.shouldBeAllDay,
|
|
type: .init(from: event),
|
|
calendar: .init(from: calendar),
|
|
participants: .init(from: event),
|
|
timeZone: calendar.isSubscribed || calendar.isDelegate ? nil : event.timeZone,
|
|
hasRecurrenceRules: event.hasRecurrenceRules || event.isDetached,
|
|
priority: nil,
|
|
conferenceURL: event.extractConferenceURL()
|
|
)
|
|
}
|
|
|
|
init?(from reminder: EKReminder) {
|
|
guard let calendar = reminder.calendar,
|
|
let dueDateComponents = reminder.dueDateComponents,
|
|
let date = Calendar.current.date(from: dueDateComponents)
|
|
else { return nil }
|
|
|
|
self.init(
|
|
id: reminder.calendarItemIdentifier,
|
|
start: date,
|
|
end: Calendar.current.endOfDay(for: date),
|
|
title: reminder.title ?? "",
|
|
location: reminder.location,
|
|
notes: reminder.notes,
|
|
url: reminder.url,
|
|
isAllDay: dueDateComponents.hour == nil,
|
|
type: .reminder(completed: reminder.isCompleted),
|
|
calendar: .init(from: calendar),
|
|
participants: [],
|
|
timeZone: calendar.isSubscribed || calendar.isDelegate ? nil : reminder.timeZone,
|
|
hasRecurrenceRules: reminder.hasRecurrenceRules,
|
|
priority: .init(from: reminder.priority),
|
|
conferenceURL: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
extension EventType {
|
|
init(from event: EKEvent) {
|
|
self = event.birthdayContactIdentifier != nil ? .birthday : .event(.init(from: event.currentUser?.participantStatus))
|
|
}
|
|
}
|
|
|
|
extension AttendanceStatus {
|
|
init(from status: EKParticipantStatus?) {
|
|
switch status {
|
|
case .accepted:
|
|
self = .accepted
|
|
case .tentative:
|
|
self = .maybe
|
|
case .declined:
|
|
self = .declined
|
|
case .pending:
|
|
self = .pending
|
|
default:
|
|
self = .unknown
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Array where Element == Participant {
|
|
init(from event: EKEvent) {
|
|
var participants = event.attendees ?? []
|
|
if let organizer = event.organizer, !participants.contains(where: { $0.url == organizer.url }) {
|
|
participants.append(organizer)
|
|
}
|
|
self.init(
|
|
participants.map { .init(from: $0, isOrganizer: $0.url == event.organizer?.url) }
|
|
)
|
|
}
|
|
}
|
|
|
|
extension Participant {
|
|
init(from participant: EKParticipant, isOrganizer: Bool) {
|
|
self.init(
|
|
name: participant.name ?? participant.url.absoluteString.replacingOccurrences(of: "mailto:", with: ""),
|
|
status: .init(from: participant.participantStatus),
|
|
isOrganizer: isOrganizer,
|
|
isCurrentUser: participant.isCurrentUser
|
|
)
|
|
}
|
|
}
|
|
|
|
extension Priority {
|
|
init?(from p: Int) {
|
|
switch p {
|
|
case 1...4:
|
|
self = .high
|
|
case 5:
|
|
self = .medium
|
|
case 6...9:
|
|
self = .low
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helper Extensions
|
|
|
|
private extension EKCalendar {
|
|
var accountTitle: String {
|
|
switch source.sourceType {
|
|
case .local, .subscribed, .birthdays:
|
|
return "Other"
|
|
default:
|
|
return source.title
|
|
}
|
|
}
|
|
|
|
var isDelegate: Bool {
|
|
return source.isDelegate
|
|
}
|
|
}
|
|
|
|
private extension EKEvent {
|
|
var currentUser: EKParticipant? {
|
|
attendees?.first(where: \.isCurrentUser)
|
|
}
|
|
|
|
var shouldBeAllDay: Bool {
|
|
guard !isAllDay else { return true }
|
|
let calendar = Calendar.current
|
|
let startOfDay = calendar.startOfDay(for: startDate)
|
|
let endOfDay = calendar.dateInterval(of: .day, for: endDate)?.end
|
|
return startDate == startOfDay && endDate == endOfDay
|
|
}
|
|
|
|
/// Extract conference call URL from various sources
|
|
func extractConferenceURL() -> URL? {
|
|
// First try the URL field if it's a conference URL
|
|
if let eventURL = url, isConferenceURL(eventURL) {
|
|
return eventURL
|
|
}
|
|
|
|
// Then try to extract from location field
|
|
if let location = location, let conferenceURL = extractURLFromText(location) {
|
|
return conferenceURL
|
|
}
|
|
|
|
// Finally try to extract from notes
|
|
if let notes = notes, let conferenceURL = extractURLFromText(notes) {
|
|
return conferenceURL
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Check if a URL is likely a conference URL
|
|
private func isConferenceURL(_ url: URL) -> Bool {
|
|
let host = url.host?.lowercased() ?? ""
|
|
let conferenceHosts = [
|
|
"zoom.us",
|
|
"teams.microsoft.com",
|
|
"meet.google.com",
|
|
"webex.com",
|
|
"gotomeeting.com",
|
|
"bluejeans.com",
|
|
"whereby.com",
|
|
"meet.jit.si",
|
|
"discord.gg",
|
|
"discord.com",
|
|
"facetime.apple.com"
|
|
]
|
|
|
|
return conferenceHosts.contains { host.contains($0) }
|
|
}
|
|
|
|
/// Extract first valid conference URL from text
|
|
private func extractURLFromText(_ text: String) -> URL? {
|
|
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
|
let matches = detector?.matches(in: text, options: [], range: NSRange(text.startIndex..., in: text))
|
|
|
|
for match in matches ?? [] {
|
|
if let url = match.url, isConferenceURL(url) {
|
|
return url
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private extension Calendar {
|
|
func endOfDay(for date: Date) -> Date {
|
|
dateInterval(of: .day, for: date)?.end ?? date
|
|
}
|
|
}
|