- 달력 툴바 '기본 표시 설정'(DiaryDefaultsSheet): 캘린더 일정 기본 모드(표시 안 함/모든 캘린더/ 선택한 캘린더만) + 목표·행동 기본 숨김 토글. 기기 로컬 standard(diary.default*) 저장 - 날짜별 재정의: 기존 동기화 배열에 마커(#override)만 추가 — 스키마 무변경, 구버전(1.0/1.1)은 마커를 무시해 표시 동일, 1.1 이전 날짜별 선택(마커 없는 목록)은 재정의로 계속 존중 - 기본값은 소급 적용(재정의 없는 모든 날짜가 현재 기본값을 따름), 각 필터 시트에 '기본값으로 되돌리기' 추가. 행동 필터 onChange는 효과값과 같으면 저장 안 함(기본값 굳음 방지) - 효과 계산 단일 지점(DiaryDefaults.effective*)을 화면·PDF/이미지 내보내기·캘린더 블록이 공유 — 기본값 날짜·재정의 날짜 모두 화면=내보내기 일치(내보내기 실검증 완료) - 검증: 요약 카드 목표 숨김 배지·타임테이블 행동 숨김·캘린더 '모든 캘린더' 자동 표시(점선)· 목표 필터 효과 반영 스크린샷 + 이미지 내보내기 왕복 + 자가 테스트 회귀 ALL PASS - 도움말(일정·필터 주제) 확장 + en/ja 14키 번역(missing 0, ja 실렌더 확인), CLAUDE.md §6.7·§14 - DEBUG: -diaryShowDefaults(시트), -diaryDefaultsSeed(기본값 주입 검증) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FVeduv1eNdXjk1ay4tSgBg
243 lines
10 KiB
Swift
243 lines
10 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 }
|
|
}
|
|
|
|
/// 이 일기 날짜에 표시할 일정들을 타임테이블 블록으로 변환 (하루 범위로 클램프).
|
|
/// 날짜별 선택(재정의)이 없으면 기기 기본값(캘린더 기본 표시 — DiaryDefaults, 1.2)을 적용한다.
|
|
/// 화면과 내보내기가 이 한 곳을 공유하므로 두 결과가 항상 일치한다.
|
|
static func timetableBlocks(for entry: DiaryEntry, math: DayMath) -> [ExportTimetableData.Block] {
|
|
let range = math.dayRange(forKey: entry.dayKey)
|
|
return DiaryDefaults.effectiveEvents(for: entry, events: events(on: entry.dayKey, math: math))
|
|
.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)
|
|
}
|
|
// -diaryDefaultsSeed와 함께면 날짜별 선택을 만들지 않는다 —
|
|
// 기본 표시 설정(캘린더 기본 모드)의 적용 경로를 그대로 검증하기 위함
|
|
if entry.calendarEventIDs.isEmpty && !UserDefaults.standard.bool(forKey: "diaryDefaultsSeed") {
|
|
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
|
|
/// 이 날짜의 효과 선택 (재정의 없으면 기기 기본값 적용 결과 — 토글 시 재정의로 저장)
|
|
@State private var selected: Set<String> = []
|
|
|
|
private var math: DayMath { DayMath() }
|
|
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("선택한 일정만 이 날짜의 타임테이블에 함께 표시돼요. 날짜마다 따로 선택할 수 있고, 일기 내보내기에도 그대로 담겨요.")
|
|
}
|
|
if !entry.calendarEventIDs.isEmpty {
|
|
Section {
|
|
Button("기본값으로 되돌리기") {
|
|
entry.calendarEventIDs = []
|
|
entry.updatedAt = .now
|
|
try? context.save()
|
|
dismiss()
|
|
}
|
|
.font(.callout)
|
|
} 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)
|
|
// 효과 선택으로 초기화 — 재정의가 없으면 기본값(캘린더 기본 표시)이 체크돼 보인다
|
|
selected = Set(DiaryDefaults.effectiveEvents(for: entry, events: events)
|
|
.compactMap(\.eventIdentifier))
|
|
}
|
|
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 }
|
|
if selected.contains(id) {
|
|
selected.remove(id)
|
|
} else {
|
|
selected.insert(id)
|
|
}
|
|
save(Array(selected))
|
|
}
|
|
|
|
private func setAll(_ on: Bool) {
|
|
selected = on ? Set(events.compactMap(\.eventIdentifier)) : []
|
|
save(Array(selected))
|
|
}
|
|
|
|
/// 날짜별 재정의(마커 + 목록)로 저장 — 기본값과 분리 (DiaryDefaults, 1.2)
|
|
private func save(_ ids: [String]) {
|
|
entry.calendarEventIDs = DiaryDefaults.overrideList(ids)
|
|
entry.updatedAt = .now
|
|
try? context.save()
|
|
}
|
|
}
|