feat(calendar): add EventKit calendar sync — read events, convert to timers, auto-sync every 15m
This commit is contained in:
parent
2fc277b663
commit
351410ba41
235
ios/ChronoMind/Shared/Calendar/CalendarSyncManager.swift
Normal file
235
ios/ChronoMind/Shared/Calendar/CalendarSyncManager.swift
Normal 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")
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user