Files
aris-old/IrisCompanion/iris/DataSources/CalendarDataSource.swift

193 lines
6.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// CalendarDataSource.swift
// iris
//
// Created by Codex.
//
import EventKit
import Foundation
struct CalendarDataSourceConfig: Sendable {
var lookaheadSec: Int = 2 * 60 * 60
var soonWindowSec: Int = 30 * 60
var maxCandidates: Int = 3
var includeAllDay: Bool = false
var includeDeclined: Bool = false
init() {}
}
final class CalendarDataSource {
struct Event: Sendable, Equatable {
let id: String
let title: String
let startAt: Int
let endAt: Int
let isAllDay: Bool
let location: String?
}
struct Data: Sendable, Equatable {
let events: [Event]
}
struct Snapshot: Sendable, Equatable {
let data: Data
let diagnostics: [String: String]
}
enum CalendarError: Error, LocalizedError, Sendable, Equatable {
case accessNotGranted(diagnostics: [String: String])
var errorDescription: String? {
switch self {
case .accessNotGranted:
return "Calendar access not granted."
}
}
var diagnostics: [String: String] {
switch self {
case .accessNotGranted(let diagnostics):
return diagnostics
}
}
}
private let store: EKEventStore
private let config: CalendarDataSourceConfig
init(store: EKEventStore = EKEventStore(), config: CalendarDataSourceConfig = .init()) {
self.store = store
self.config = config
}
func dataWithDiagnostics(now: Int) async throws -> Snapshot {
var diagnostics: [String: String] = [
"now": String(now),
"lookahead_sec": String(config.lookaheadSec),
"soon_window_sec": String(config.soonWindowSec),
]
let nowDate = Date(timeIntervalSince1970: TimeInterval(now))
let endDate = nowDate.addingTimeInterval(TimeInterval(config.lookaheadSec))
let auth = calendarAuthorizationStatusString()
diagnostics["auth"] = auth
let accessGranted = await ensureCalendarAccess()
diagnostics["access_granted"] = accessGranted ? "true" : "false"
guard accessGranted else {
throw CalendarError.accessNotGranted(diagnostics: diagnostics)
}
let predicate = store.predicateForEvents(withStart: nowDate, end: endDate, calendars: nil)
let events = store.events(matching: predicate)
diagnostics["events_matched"] = String(events.count)
let filtered = events
.filter { shouldInclude(event: $0) }
.sorted { $0.startDate < $1.startDate }
diagnostics["events_filtered"] = String(filtered.count)
let outputEvents = buildEvents(from: filtered, nowDate: nowDate)
diagnostics["events_output"] = String(outputEvents.count)
return Snapshot(data: Data(events: outputEvents), diagnostics: diagnostics)
}
private func shouldInclude(event: EKEvent) -> Bool {
if event.isAllDay, !config.includeAllDay {
return false
}
if !config.includeDeclined {
// `EKEvent.participants` is optional; safest check is `eventStatus` and organizer availability.
// Many calendars wont provide participants; keep it simple for Phase 1.
if event.status == .canceled {
return false
}
}
return true
}
private func buildEvents(from events: [EKEvent], nowDate: Date) -> [Event] {
var results: [Event] = []
results.reserveCapacity(min(config.maxCandidates, events.count))
for event in events {
if results.count >= config.maxCandidates { break }
guard let start = event.startDate, let end = event.endDate else { continue }
let isOngoing = start <= nowDate && end > nowDate
let startsInSec = Int(start.timeIntervalSince(nowDate))
if !isOngoing, startsInSec > config.soonWindowSec {
continue
}
let id = "cal:\(event.eventIdentifier ?? UUID().uuidString):\(Int(start.timeIntervalSince1970))"
let title = (event.title ?? "Event").trimmingCharacters(in: .whitespacesAndNewlines)
results.append(
Event(
id: id,
title: title.isEmpty ? "Event" : title,
startAt: Int(start.timeIntervalSince1970),
endAt: Int(end.timeIntervalSince1970),
isAllDay: event.isAllDay,
location: event.location
)
)
}
return results
}
private func calendarAuthorizationStatusString() -> String {
if #available(iOS 17.0, *) {
return String(describing: EKEventStore.authorizationStatus(for: .event))
}
return String(describing: EKEventStore.authorizationStatus(for: .event))
}
private func ensureCalendarAccess() async -> Bool {
let status = EKEventStore.authorizationStatus(for: .event)
if #available(iOS 17.0, *) {
if status == .fullAccess { return true }
if status == .writeOnly { return false }
}
switch status {
case .notDetermined:
return await requestAccess()
case .denied, .restricted:
return false
case .authorized:
return true
@unknown default:
return false
}
}
private func requestAccess() async -> Bool {
await MainActor.run {
// Ensure the permission prompt is allowed to present.
}
return await withCheckedContinuation { continuation in
if #available(iOS 17.0, *) {
store.requestFullAccessToEvents { granted, _ in
continuation.resume(returning: granted)
}
} else {
store.requestAccess(to: .event) { granted, _ in
continuation.resume(returning: granted)
}
}
}
}
}