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