mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Calendars in the "Select Calendars" settings section are now grouped by their source account with color indicators, making it possible to distinguish calendars that share the same name across different accounts. Each account group header includes a toggle to select or deselect all calendars in that group.
358 lines
12 KiB
Swift
358 lines
12 KiB
Swift
/*
|
|
* Atoll (DynamicIsland)
|
|
* Copyright (C) 2024-2026 Atoll Contributors
|
|
*
|
|
* Originally from boring.notch project
|
|
* Modified and adapted for Atoll (DynamicIsland)
|
|
* See NOTICE for details.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
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(
|
|
accountName: calendar.accountTitle,
|
|
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
|
|
|
|
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
|
|
}
|
|
}
|