mycode/myApp/HaruDanim/IOS/Views/DiaryCalendarEvents.swift
songyc macbook 4c3b5a6c03 feat(diary): show selected calendar events in the timetable, per date
- DiaryEntry.calendarEventIDs: 날짜별로 어떤 캘린더(EventKit) 일정을 넣을지
  따로 저장 — 오늘은 포함, 내일은 미포함 같은 선택이 가능
- 타임테이블 카드 우상단 캘린더 버튼(+선택 개수 배지) → 일정 선택 팝업:
  그날의 일정 목록에서 전체 선택/해제 또는 개별 체크, 권한 미허용 시 설정
  이동 안내. 종일 일정은 제외, 하루 경계에 걸치면 잘라서 표시
- 일정은 행동 블록과 구분되는 외곽선+은은한 채움 스타일(캘린더 색)로 렌더,
  일기 내보내기(PDF/이미지)에도 동일하게 포함 — 내보내기 요약도 화면과
  같은 24시간 타임테이블로 통일
- NSCalendarsFullAccessUsageDescription 추가, 새 문자열 en/ja 번역
- 검증 인자: -diarySeedEvents YES, -diaryShowCalendarPicker YES

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-13 17:31:09 +09:00

224 lines
9.0 KiB
Swift

//
// 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<String> { 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()
}
}