From 351410ba416fba8cc9a6e138367fb305c4b2b90a Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 22:35:36 -0800 Subject: [PATCH] =?UTF-8?q?feat(calendar):=20add=20EventKit=20calendar=20s?= =?UTF-8?q?ync=20=E2=80=94=20read=20events,=20convert=20to=20timers,=20aut?= =?UTF-8?q?o-sync=20every=2015m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Shared/Calendar/CalendarSyncManager.swift | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 ios/ChronoMind/Shared/Calendar/CalendarSyncManager.swift diff --git a/ios/ChronoMind/Shared/Calendar/CalendarSyncManager.swift b/ios/ChronoMind/Shared/Calendar/CalendarSyncManager.swift new file mode 100644 index 0000000..c02c4e8 --- /dev/null +++ b/ios/ChronoMind/Shared/Calendar/CalendarSyncManager.swift @@ -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") +}