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
This commit is contained in:
parent
4f9fdfba11
commit
4c3b5a6c03
@ -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;
|
||||
|
||||
@ -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": {
|
||||
|
||||
223
myApp/HaruDanim/IOS/Views/DiaryCalendarEvents.swift
Normal file
223
myApp/HaruDanim/IOS/Views/DiaryCalendarEvents.swift
Normal file
@ -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<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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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("캘린더 일정"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user