// // DiaryCalendarEvents.swift // Haru_Danim // // 일기 타임테이블에 캘린더(EventKit) 일정 표시. // - 날짜마다 어떤 일정을 넣을지 DiaryEntry.calendarEventIDs로 따로 저장한다 // (오늘은 포함, 내일은 미포함 같은 선택이 가능). // - DiaryCalendarPickerSheet: 타임테이블 카드의 버튼으로 여는 선택 팝업. // 그날의 일정 목록에서 전체 또는 일부를 체크해 넣는다. // - 화면과 내보내기(ExportSnapshot.injectingCalendarEvents) 모두 같은 블록을 쓴다. // import SwiftUI import SwiftData import EventKit // MARK: - EventKit 헬퍼 @MainActor enum DiaryCalendarEvents { static let store = EKEventStore() static var isAuthorized: Bool { EKEventStore.authorizationStatus(for: .event) == .fullAccess } /// 권한 요청 (미결정 상태일 때만 시스템 팝업) static func requestAccess() async -> Bool { if isAuthorized { return true } return (try? await store.requestFullAccessToEvents()) ?? false } /// 논리적 하루(하루 시작 시간 반영)에 걸치는 일정들. 종일 일정은 제외. static func events(on dayKey: Date, math: DayMath) -> [EKEvent] { guard isAuthorized else { return [] } let range = math.dayRange(forKey: dayKey) let predicate = store.predicateForEvents( withStart: range.lowerBound, end: range.upperBound, calendars: nil ) return store.events(matching: predicate) .filter { !$0.isAllDay } .sorted { $0.startDate < $1.startDate } } /// 이 일기 날짜에 선택된 일정들을 타임테이블 블록으로 변환 (하루 범위로 클램프) static func timetableBlocks(for entry: DiaryEntry, math: DayMath) -> [ExportTimetableData.Block] { let ids = Set(entry.calendarEventIDs) guard !ids.isEmpty else { return [] } let range = math.dayRange(forKey: entry.dayKey) return events(on: entry.dayKey, math: math) .filter { ids.contains($0.eventIdentifier ?? "") } .compactMap { event in let lower = max(event.startDate, range.lowerBound) let upper = min(event.endDate ?? event.startDate, range.upperBound) guard lower < upper else { return nil } return ExportTimetableData.Block( startFrac: lower.timeIntervalSince(range.lowerBound) / 3600, endFrac: upper.timeIntervalSince(range.lowerBound) / 3600, color: color(of: event), symbol: "calendar", name: event.title ?? String(localized: "일정") ) } } static func color(of event: EKEvent) -> Color { if let cgColor = event.calendar?.cgColor { return Color(cgColor: cgColor) } return .gray } #if DEBUG /// 검증용 (-diarySeedEvents YES): 오늘 캘린더에 데모 일정 3개를 만들고 앞의 2개를 이 일기에 선택 static func seedForVerification(entry: DiaryEntry, math: DayMath) async { guard UserDefaults.standard.bool(forKey: "diarySeedEvents") else { return } guard await requestAccess() else { return } var todays = events(on: entry.dayKey, math: math) if todays.isEmpty { let cal = Calendar.current let base = math.dayRange(forKey: entry.dayKey).lowerBound let samples: [(String, Int, Int)] = [("팀 회의", 10, 90), ("치과 예약", 14, 40), ("저녁 약속", 19, 120)] for (title, hour, minutes) in samples { let event = EKEvent(eventStore: store) event.title = title event.startDate = cal.date(byAdding: .hour, value: hour, to: base)! event.endDate = event.startDate.addingTimeInterval(Double(minutes) * 60) event.calendar = store.defaultCalendarForNewEvents try? store.save(event, span: .thisEvent) } todays = events(on: entry.dayKey, math: math) } if entry.calendarEventIDs.isEmpty { entry.calendarEventIDs = todays.prefix(2).compactMap(\.eventIdentifier) } } #endif } // MARK: - 일정 선택 팝업 /// 그날의 캘린더 일정 중 타임테이블에 표시할 것을 고르는 시트. /// 전체 선택/해제와 개별 체크를 지원하고, 선택은 이 날짜에만 적용된다. struct DiaryCalendarPickerSheet: View { @Bindable var entry: DiaryEntry @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var context @State private var events: [EKEvent] = [] @State private var accessDenied = false @State private var loaded = false private var math: DayMath { DayMath() } private var selected: Set { Set(entry.calendarEventIDs) } private var allSelected: Bool { !events.isEmpty && events.allSatisfy { selected.contains($0.eventIdentifier ?? "") } } var body: some View { NavigationStack { List { if accessDenied { Section { Label("캘린더 접근이 꺼져 있어요", systemImage: "calendar.badge.exclamationmark") .font(.callout) Button("설정에서 허용하기") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } } footer: { Text("설정 → 앱 → 하루 다님에서 캘린더 접근을 허용하면 일정을 타임테이블에 넣을 수 있어요.") } } else if loaded && events.isEmpty { Text("이 날짜에 캘린더 일정이 없어요") .font(.callout) .foregroundStyle(.secondary) } else { Section { Button(allSelected ? String(localized: "전체 해제") : String(localized: "전체 선택")) { setAll(!allSelected) } .font(.callout.weight(.semibold)) ForEach(events, id: \.eventIdentifier) { event in eventRow(event) } } footer: { Text("선택한 일정만 이 날짜의 타임테이블에 함께 표시돼요. 날짜마다 따로 선택할 수 있고, 일기 내보내기에도 그대로 담겨요.") } } } .scrollContentBackground(.hidden) .background(AppTheme.background) .navigationTitle("캘린더 일정") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("닫기") { dismiss() } } } } .presentationDetents([.medium, .large]) .task { let granted = await DiaryCalendarEvents.requestAccess() accessDenied = !granted if granted { events = DiaryCalendarEvents.events(on: entry.dayKey, math: math) } loaded = true } } private func eventRow(_ event: EKEvent) -> some View { let id = event.eventIdentifier ?? "" let isOn = selected.contains(id) return Button { toggle(id) } label: { HStack(spacing: 10) { Circle() .fill(DiaryCalendarEvents.color(of: event)) .frame(width: 10, height: 10) VStack(alignment: .leading, spacing: 1) { Text(event.title ?? String(localized: "일정")) .font(.subheadline) .foregroundStyle(.primary) .lineLimit(1) Text("\(Format.time(event.startDate)) ~ \(Format.time(event.endDate ?? event.startDate))") .font(.caption2) .foregroundStyle(.secondary) } Spacer() Image(systemName: isOn ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(isOn ? AppTheme.green : .secondary) } .contentShape(Rectangle()) } .buttonStyle(.plain) } private func toggle(_ id: String) { guard !id.isEmpty else { return } var ids = entry.calendarEventIDs if let index = ids.firstIndex(of: id) { ids.remove(at: index) } else { ids.append(id) } save(ids) } private func setAll(_ on: Bool) { save(on ? events.compactMap(\.eventIdentifier) : []) } private func save(_ ids: [String]) { entry.calendarEventIDs = ids entry.updatedAt = .now try? context.save() } }