// ── 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, type: .alarm, label: event.title ?? "Calendar Event", description: event.location, urgency: urgency, state: .active, targetTime: startDate, duration: event.endDate.timeIntervalSince(startDate), createdAt: Date(), startedAt: Date(), elapsedBeforePause: 0, cascade: CascadeConfig(preset: .standard, intervals: []), warnings: calculateCascadeWarnings( targetTime: startDate, intervals: CascadePreset.standard.defaultIntervals, now: Date() ), snoozeCount: 0, category: "calendar" ) 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") }