- Fix CloudKitSyncManager deinit concurrency violation - Replace deprecated List(selection:) with VStack+ForEach sidebar - Replace removed Animation.none with Animation.linear(duration: 0) - Fix CountdownRing initializer parameter mismatch - Unwrap optional timer.duration in ShareableTimerManager and DataExportManager - Add missing .event case to exhaustive switch - Change CascadeWarning.scheduledTime from let to var - Fix CalendarSyncManager CMTimer init (add elapsedBeforePause, remove non-existent params) - Add missing UserNotifications import in SleepManager - Remove parameterized App Intents phrases (iOS 26 restriction) - Temporarily remove watchOS target dependency for iOS build
234 lines
7.8 KiB
Swift
234 lines
7.8 KiB
Swift
// ── 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")
|
|
}
|