feat(calendar): add EventKit calendar sync — read events, convert to timers, auto-sync every 15m

This commit is contained in:
saravanakumardb1 2026-02-27 22:35:36 -08:00
parent 2fc277b663
commit 351410ba41

View File

@ -0,0 +1,235 @@
// Calendar Sync Manager
// EventKit integration: read Apple Calendar events, convert to timers
// One-way sync: calendar timers (read-only, no write-back)
import Foundation
import EventKit
import Combine
@MainActor
final class CalendarSyncManager: ObservableObject {
static let shared = CalendarSyncManager()
@Published var syncEnabled: Bool {
didSet { UserDefaults.standard.set(syncEnabled, forKey: enabledKey) }
}
@Published var authorizationStatus: EKAuthorizationStatus = .notDetermined
@Published var syncedCalendars: [SyncedCalendar] = []
@Published var lastSyncDate: Date?
@Published var isSyncing = false
private let eventStore = EKEventStore()
private let enabledKey = "chronomind-calendar-sync-enabled"
private let calendarsKey = "chronomind-synced-calendars"
private let syncedTimerPrefix = "cal-sync-"
private var syncTimer: Timer?
private init() {
syncEnabled = UserDefaults.standard.bool(forKey: enabledKey)
loadSyncedCalendars()
updateAuthStatus()
}
// MARK: - Authorization
func requestAccess() async -> Bool {
if #available(iOS 17.0, macOS 14.0, *) {
let granted = try? await eventStore.requestFullAccessToEvents()
updateAuthStatus()
return granted ?? false
} else {
let granted = try? await eventStore.requestAccess(to: .event)
updateAuthStatus()
return granted ?? false
}
}
private func updateAuthStatus() {
authorizationStatus = EKEventStore.authorizationStatus(for: .event)
}
// MARK: - Available Calendars
var availableCalendars: [EKCalendar] {
eventStore.calendars(for: .event)
}
func toggleCalendar(_ identifier: String) {
if let index = syncedCalendars.firstIndex(where: { $0.identifier == identifier }) {
syncedCalendars[index].enabled.toggle()
} else {
if let cal = availableCalendars.first(where: { $0.calendarIdentifier == identifier }) {
syncedCalendars.append(SyncedCalendar(
identifier: cal.calendarIdentifier,
title: cal.title,
color: cal.cgColor,
enabled: true
))
}
}
saveSyncedCalendars()
}
// MARK: - Sync
func startAutoSync(interval: TimeInterval = 900) { // 15 minutes
stopAutoSync()
syncTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { @MainActor in
await self?.sync()
}
}
// Initial sync
Task { await sync() }
}
func stopAutoSync() {
syncTimer?.invalidate()
syncTimer = nil
}
func sync() async {
guard syncEnabled else { return }
guard authorizationStatus == .fullAccess || authorizationStatus == .authorized else { return }
isSyncing = true
defer { isSyncing = false }
let enabledIdentifiers = Set(syncedCalendars.filter(\.enabled).map(\.identifier))
guard !enabledIdentifiers.isEmpty else { return }
let calendars = availableCalendars.filter { enabledIdentifiers.contains($0.calendarIdentifier) }
guard !calendars.isEmpty else { return }
// Fetch events for the next 7 days
let now = Date()
let endDate = Calendar.current.date(byAdding: .day, value: 7, to: now)!
let predicate = eventStore.predicateForEvents(withStart: now, end: endDate, calendars: calendars)
let events = eventStore.events(matching: predicate)
// Convert events to synced timers
let syncedTimers = events.compactMap { convertEventToTimer($0) }
// Post notification with synced timers
NotificationCenter.default.post(
name: .chronoMindCalendarSyncDidComplete,
object: nil,
userInfo: ["timers": syncedTimers]
)
lastSyncDate = Date()
UserDefaults.standard.set(lastSyncDate, forKey: "chronomind-calendar-last-sync")
}
// MARK: - Event Timer Conversion
private func convertEventToTimer(_ event: EKEvent) -> CMTimer? {
guard let startDate = event.startDate else { return nil }
// Skip all-day events
guard !event.isAllDay else { return nil }
// Skip events that have already ended
guard event.endDate > Date() else { return nil }
let urgency: UrgencyLevel
switch event.calendar.type {
case .birthday: urgency = .gentle
default: urgency = .standard
}
// Use a deterministic ID based on the event identifier
let stableId = "\(syncedTimerPrefix)\(event.eventIdentifier ?? UUID().uuidString)"
var timer = CMTimer(
id: stableId,
label: event.title ?? "Calendar Event",
description: event.location,
type: .alarm,
state: .active,
urgency: urgency,
duration: event.endDate.timeIntervalSince(startDate),
targetTime: startDate,
createdAt: Date(),
startedAt: Date(),
category: "calendar",
cascade: CascadeConfig(preset: .standard, intervals: []),
warnings: calculateCascadeWarnings(
targetTime: startDate,
intervals: CascadePreset.standard.defaultIntervals,
now: Date()
),
snoozeCount: 0,
isCalendarSync: true,
calendarEventId: event.eventIdentifier,
calendarColor: event.calendar.cgColor
)
return timer
}
// MARK: - Persistence
private func loadSyncedCalendars() {
guard let data = UserDefaults.standard.data(forKey: calendarsKey),
let decoded = try? JSONDecoder().decode([SyncedCalendar].self, from: data) else { return }
syncedCalendars = decoded
}
private func saveSyncedCalendars() {
if let data = try? JSONEncoder().encode(syncedCalendars) {
UserDefaults.standard.set(data, forKey: calendarsKey)
}
}
}
// MARK: - Synced Calendar Model
struct SyncedCalendar: Codable, Identifiable {
let identifier: String
let title: String
var enabled: Bool
var id: String { identifier }
// CoreGraphics color can't be directly Codable, store as hex
private let colorHex: String?
init(identifier: String, title: String, color: CGColor?, enabled: Bool) {
self.identifier = identifier
self.title = title
self.enabled = enabled
self.colorHex = color.flatMap { Self.hexFromCGColor($0) }
}
var displayColor: CGColor? {
colorHex.flatMap { Self.cgColorFromHex($0) }
}
private static func hexFromCGColor(_ color: CGColor) -> String? {
guard let components = color.components, components.count >= 3 else { return nil }
let r = Int(components[0] * 255)
let g = Int(components[1] * 255)
let b = Int(components[2] * 255)
return String(format: "#%02X%02X%02X", r, g, b)
}
private static func cgColorFromHex(_ hex: String) -> CGColor? {
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
guard hexSanitized.count == 6 else { return nil }
var rgb: UInt64 = 0
Scanner(string: hexSanitized).scanHexInt64(&rgb)
return CGColor(
red: CGFloat((rgb >> 16) & 0xFF) / 255.0,
green: CGFloat((rgb >> 8) & 0xFF) / 255.0,
blue: CGFloat(rgb & 0xFF) / 255.0,
alpha: 1.0
)
}
}
// MARK: - Notification Names
extension Notification.Name {
static let chronoMindCalendarSyncDidComplete = Notification.Name("chronoMindCalendarSyncDidComplete")
}