diff --git a/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj b/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj index d878004..d0bad53 100644 --- a/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj +++ b/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj @@ -602,6 +602,7 @@ INFOPLIST_KEY_CFBundleDisplayName = "하루 다님"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "내보낸 리포트 이미지를 사진 앨범에 저장하기 위해 필요해요."; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "일기 타임테이블에 캘린더 일정을 함께 표시하기 위해 필요해요."; INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -642,6 +643,7 @@ INFOPLIST_KEY_CFBundleDisplayName = "하루 다님"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "내보낸 리포트 이미지를 사진 앨범에 저장하기 위해 필요해요."; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "일기 타임테이블에 캘린더 일정을 함께 표시하기 위해 필요해요."; INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index 3a06b3a..6fc32b4 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -4868,6 +4868,22 @@ } } }, + "선택한 일정만 이 날짜의 타임테이블에 함께 표시돼요. 날짜마다 따로 선택할 수 있고, 일기 내보내기에도 그대로 담겨요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only the selected events appear in this date’s timetable. You can choose differently for each date, and they’re included in diary exports too." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したイベントだけがこの日付のタイムテーブルに表示されます。日付ごとに別々に選べ、日記の書き出しにもそのまま含まれます。" + } + } + } + }, "선택한 탭(최대 %lld개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요.": { "localizations": { "en": { @@ -4964,6 +4980,22 @@ } } }, + "설정 → 앱 → 하루 다님에서 캘린더 접근을 허용하면 일정을 타임테이블에 넣을 수 있어요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow calendar access in Settings → Apps → Haru Danim to add events to the timetable." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定→アプリ→Haru Danimでカレンダーへのアクセスを許可すると、イベントをタイムテーブルに追加できます。" + } + } + } + }, "설정 → 프리미엄에서 '기기 간 동기화'를 켜면 iCloud로 아이폰·아이패드의 데이터가 하나로 합쳐져요. 켠 뒤에는 앱을 완전히 종료했다가 다시 실행해야 적용돼요. 모든 기기가 같은 Apple 계정으로 로그인되어 있어야 해요.": { "localizations": { "en": { @@ -4996,6 +5028,22 @@ } } }, + "설정에서 허용하기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow in Settings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定で許可する" + } + } + } + }, "설정한 시간보다 짧게 측정하고 종료한 기록은 저장하지 않아요. 버튼을 실수로 눌렀을 때 유용해요.": { "localizations": { "en": { @@ -6343,6 +6391,22 @@ } } }, + "이 날짜에 캘린더 일정이 없어요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No calendar events on this date" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この日付にカレンダーのイベントはありません" + } + } + } + }, "이 목표를 이루기 위한 다짐(행동/꼬리표 + 주기 + 목표량)을 추가하세요.": { "localizations": { "en": { @@ -6825,6 +6889,22 @@ } } }, + "일정": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Event" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イベント" + } + } + } + }, "일주일": { "localizations": { "en": { @@ -7049,6 +7129,22 @@ } } }, + "전체 해제": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Deselect All" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて解除" + } + } + } + }, "접기": { "localizations": { "en": { @@ -7881,6 +7977,38 @@ } } }, + "캘린더 일정": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar Events" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カレンダーのイベント" + } + } + } + }, + "캘린더 접근이 꺼져 있어요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar access is off" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カレンダーへのアクセスがオフです" + } + } + } + }, "컴플리케이션 추가": { "localizations": { "en": { diff --git a/myApp/HaruDanim/IOS/Views/DiaryCalendarEvents.swift b/myApp/HaruDanim/IOS/Views/DiaryCalendarEvents.swift new file mode 100644 index 0000000..1cc462b --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/DiaryCalendarEvents.swift @@ -0,0 +1,223 @@ +// +// 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() + } +} diff --git a/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift b/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift index 2d27580..ab8db99 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift @@ -229,8 +229,11 @@ struct DiaryExportSheet: View { let snapshot = ExportBuilder.history( dayKey: entry.dayKey, weeklyTimetable: false, sessions: sessions, entries: countEntries, - orderedActions: orderedActions, excludedActionIDs: [], math: math + orderedActions: orderedActions, excludedActionIDs: [], math: math, + trimHours: false ) + // 화면(하루 정리)과 동일하게 24시간 전체 + 선택한 캘린더 일정을 담는다 + .injectingCalendarEvents(DiaryCalendarEvents.timetableBlocks(for: entry, math: math)) let summary = DiaryPrintSummaryView(entry: entry, snapshot: snapshot) let summaryRenderer = ImageRenderer(content: summary) summaryRenderer.scale = 2 diff --git a/myApp/HaruDanim/IOS/Views/DiaryView.swift b/myApp/HaruDanim/IOS/Views/DiaryView.swift index 1c212b5..41492e3 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryView.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryView.swift @@ -611,6 +611,16 @@ private struct DiarySummaryPage: View { @AppStorage(DiarySectionConfig.orderKey) private var sectionOrderRaw = "" @AppStorage(DiarySectionConfig.hiddenKey) private var hiddenSectionsRaw = "" + /// 이 날짜에 선택된 캘린더 일정 → 타임테이블 블록 (선택 변경 시 .task로 재조회) + @State private var calendarBlocks: [ExportTimetableData.Block] = [] + @State private var showingCalendarPicker: Bool = { + #if DEBUG + // 검증용: -diaryShowCalendarPicker YES → 일정 선택 팝업 바로 표시 + if UserDefaults.standard.bool(forKey: "diaryShowCalendarPicker") { return true } + #endif + return false + }() + private var math: DayMath { DayMath() } /// 표시할 섹션 (구성 설정의 순서, 숨김 제외) @@ -714,6 +724,15 @@ private struct DiarySummaryPage: View { } } .onAppear(perform: DiarySectionConfig.migrateIfNeeded) + .sheet(isPresented: $showingCalendarPicker) { + DiaryCalendarPickerSheet(entry: entry) + } + .task(id: entry.calendarEventIDs) { + #if DEBUG + await DiaryCalendarEvents.seedForVerification(entry: entry, math: math) + #endif + calendarBlocks = DiaryCalendarEvents.timetableBlocks(for: entry, math: math) + } } /// 섹션 종류 → 실제 카드 뷰 @@ -758,11 +777,34 @@ private struct DiarySummaryPage: View { } } + /// 타임테이블 카드 + 우상단 캘린더 일정 버튼 (누르면 그날 넣을 일정을 고르는 팝업) @ViewBuilder private func timetableSections(innerWidth: CGFloat) -> some View { - ForEach(Array(daySnapshot.sections.enumerated()), id: \.offset) { _, section in + let injected = daySnapshot.injectingCalendarEvents(calendarBlocks) + ForEach(Array(injected.sections.enumerated()), id: \.offset) { _, section in if case .timetable = section { - ExportSectionView(section: section, innerWidth: innerWidth) + ZStack(alignment: .topTrailing) { + ExportSectionView(section: section, innerWidth: innerWidth) + Button { + showingCalendarPicker = true + } label: { + HStack(spacing: 4) { + Image(systemName: "calendar.badge.plus") + .font(.system(size: 12, weight: .semibold)) + if !calendarBlocks.isEmpty { + Text(verbatim: "\(calendarBlocks.count)") + .font(.system(size: 11, weight: .bold).monospacedDigit()) + } + } + .foregroundStyle(AppTheme.green) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(AppTheme.green.opacity(0.12), in: Capsule()) + } + .buttonStyle(.plain) + .padding(10) + .accessibilityLabel(Text("캘린더 일정")) + } } } } diff --git a/myApp/HaruDanim/IOS/Views/ExportImageView.swift b/myApp/HaruDanim/IOS/Views/ExportImageView.swift index ea99937..990cb3a 100644 --- a/myApp/HaruDanim/IOS/Views/ExportImageView.swift +++ b/myApp/HaruDanim/IOS/Views/ExportImageView.swift @@ -33,6 +33,19 @@ struct ExportSnapshot { var fileSlug: String var fileName: String { "하루다님-\(fileSlug).png" } + + /// 하루짜리 타임테이블 섹션에 캘린더 일정 블록을 끼워 넣는다 (일기 화면·내보내기 공용) + func injectingCalendarEvents(_ events: [ExportTimetableData.Block]) -> ExportSnapshot { + guard !events.isEmpty else { return self } + var copy = self + copy.sections = sections.map { section in + guard case .timetable(let title, let subtitle, var data) = section, + data.days.count == 1 else { return section } + data.days[0].eventBlocks = events + return .timetable(title: title, subtitle: subtitle, data) + } + return copy + } } struct ExportHeroStat: Identifiable { @@ -108,6 +121,8 @@ struct ExportTimetableData { var emphasized: Bool var blocks: [Block] var markers: [Marker] + /// 캘린더(EventKit) 일정 — 행동 블록 아래에 외곽선 스타일로 깔린다 (일기 전용) + var eventBlocks: [Block] = [] } /// 하루 시작 시각(시). 축 라벨은 (startHour + 오프셋) % 24 @@ -1106,6 +1121,9 @@ private struct ExportTimetableView: View { Spacer(minLength: 0) } } + ForEach(day.eventBlocks) { block in + eventBlockView(block, columnWidth: columnWidth) + } ForEach(day.blocks) { block in blockView(block, columnWidth: columnWidth) } @@ -1141,6 +1159,29 @@ private struct ExportTimetableView: View { .offset(x: 2, y: y) } + /// 캘린더 일정 블록: 행동 블록과 구분되는 은은한 채움 + 외곽선 스타일 + private func eventBlockView(_ block: ExportTimetableData.Block, columnWidth: CGFloat) -> some View { + let y = CGFloat(block.startFrac - Double(data.hourLo)) * hourHeight + let height = max(CGFloat(block.endFrac - block.startFrac) * hourHeight, 5) + return RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(block.color.opacity(0.14)) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .strokeBorder(block.color.opacity(0.55), lineWidth: 1) + ) + .overlay(alignment: .topLeading) { + if !weekly && height >= 18 { + Label(block.name, systemImage: block.symbol) + .font(.caption2) + .foregroundStyle(block.color) + .lineLimit(1) + .padding(4) + } + } + .frame(width: max(columnWidth - 4, 8), height: height) + .offset(x: 2, y: y) + } + private func markerView(_ marker: ExportTimetableData.Marker, index: Int, columnWidth: CGFloat) -> some View { let size: CGFloat = weekly ? 8 : 14 let x = 4 + CGFloat(index % 5) * (size + 3) diff --git a/myApp/HaruDanim/Shared/DiaryModels.swift b/myApp/HaruDanim/Shared/DiaryModels.swift index ed680af..a40500c 100644 --- a/myApp/HaruDanim/Shared/DiaryModels.swift +++ b/myApp/HaruDanim/Shared/DiaryModels.swift @@ -22,6 +22,9 @@ final class DiaryEntry { /// 오늘 기분 사진 (사진 앨범에서 선택, nil = 미설정) @Attribute(.externalStorage) var moodImageData: Data? = nil + /// 이 날짜의 타임테이블에 함께 표시할 캘린더 일정 ID들 (EKEvent.eventIdentifier). + /// 날짜마다 따로 선택한다 — 오늘은 포함하고 내일은 안 하는 식. + var calendarEventIDs: [String] = [] var createdAt: Date = Date() var updatedAt: Date = Date()