SPB Git

spb/poche Public

Agent personnel 100 % on-device — SwiftUI + Apple Foundation Models + EventKit + SwiftData. Aucune API, aucun serveur.

Swift 100%
5.0 KB · 138 lines swift
Raw Blame History
1//2//  EventKitBridge.swift3//  Poche4//5//  Author:  Simon-Pierre Boucher6//  Contact: contact@spboucher.ai7//89import Foundation10import EventKit1112enum BridgeError: Error {13    case remindersAccessDenied14    case calendarAccessDenied1516    var userMessage: String {17        switch self {18        case .remindersAccessDenied:19            "Poche n'a pas accès aux Rappels. Autorise l'accès dans Réglages → Confidentialité → Rappels."20        case .calendarAccessDenied:21            "Poche n'a pas accès au Calendrier. Autorise l'accès dans Réglages → Confidentialité → Calendriers."22        }23    }24}2526/// EventKit bridge. Writes are reachable only through ActionExecutor after27/// user confirmation. Permissions are requested at the moment of need,28/// never at launch (CLAUDE.md §4).29@MainActor30final class EventKitBridge {31    private let eventStore = EKEventStore()3233    // MARK: - Permissions3435    private func ensureRemindersAccess() async throws {36        switch EKEventStore.authorizationStatus(for: .reminder) {37        case .fullAccess:38            return39        case .notDetermined:40            let granted = (try? await eventStore.requestFullAccessToReminders()) ?? false41            guard granted else { throw BridgeError.remindersAccessDenied }42        default:43            throw BridgeError.remindersAccessDenied44        }45    }4647    private func ensureCalendarAccess() async throws {48        switch EKEventStore.authorizationStatus(for: .event) {49        case .fullAccess:50            return51        case .notDetermined:52            let granted = (try? await eventStore.requestFullAccessToEvents()) ?? false53            guard granted else { throw BridgeError.calendarAccessDenied }54        default:55            throw BridgeError.calendarAccessDenied56        }57    }5859    // MARK: - Writes (ActionExecutor only)6061    func createReminder(_ draft: ReminderDraft) async throws {62        try await ensureRemindersAccess()6364        let reminder = EKReminder(eventStore: eventStore)65        reminder.title = draft.title66        reminder.calendar = remindersList(named: draft.list)67            ?? eventStore.defaultCalendarForNewReminders()68        reminder.dueDateComponents = Calendar.current.dateComponents(69            [.year, .month, .day, .hour, .minute],70            from: draft.due71        )72        reminder.addAlarm(EKAlarm(absoluteDate: draft.due))73        try eventStore.save(reminder, commit: true)74    }7576    func createEvent(_ draft: EventDraft) async throws {77        try await ensureCalendarAccess()7879        let event = EKEvent(eventStore: eventStore)80        event.title = draft.title81        event.startDate = draft.start82        event.endDate = draft.end83        event.location = draft.location84        event.calendar = eventStore.defaultCalendarForNewEvents85        try eventStore.save(event, span: .thisEvent, commit: true)86    }8788    // MARK: - Reads8990    /// Output is budgeted: at most `limit` lines, one short line each91    /// (a tool returning 30 events kills the session — CLAUDE.md §4).92    func upcomingEvents(days: Int, limit: Int) async throws -> [String] {93        try await ensureCalendarAccess()9495        let start = Date.now96        let end = Calendar.current.date(byAdding: .day, value: days, to: start) ?? start97        let predicate = eventStore.predicateForEvents(withStart: start, end: end, calendars: nil)98        return eventStore.events(matching: predicate)99            .sorted { $0.startDate < $1.startDate }100            .prefix(limit)101            .map { event in102                let when = event.startDate.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())103                return "• \(when)\(event.title ?? "Sans titre")"104            }105    }106107    func upcomingReminders(limit: Int) async throws -> [String] {108        try await ensureRemindersAccess()109110        let predicate = eventStore.predicateForIncompleteReminders(111            withDueDateStarting: nil,112            ending: nil,113            calendars: nil114        )115        // EKReminder is not Sendable: render to plain strings inside the116        // fetch callback instead of carrying objects across the continuation.117        return await withCheckedContinuation { continuation in118            eventStore.fetchReminders(matching: predicate) { result in119                let lines = (result ?? [])120                    .prefix(limit)121                    .map { reminder in122                        let due = reminder.dueDateComponents?.date.map {123                            " — " + $0.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())124                        } ?? ""125                        return "• \(reminder.title ?? "Sans titre")\(due)"126                    }127                continuation.resume(returning: Array(lines))128            }129        }130    }131132    private func remindersList(named name: String?) -> EKCalendar? {133        guard let name else { return nil }134        return eventStore.calendars(for: .reminder)135            .first { $0.title.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame }136    }137}138