// // DiaryExportSheet.swift // Haru_Danim // // 일기 내보내기 — 날짜 구간(부터~까지) 또는 복수 날짜 지정(1개만도 가능)으로 // 해당 날짜들의 일기를 PDF 또는 이미지(PNG)로 만들어 공유한다. // - PDF: 일기 페이지(요약 + 노트들)를 각각 한 페이지로 담은 문서 1개 // - 이미지: 날짜마다 페이지들을 세로로 이어 붙인 PNG 1장씩 // 렌더는 화면과 동일한 논리 크기(요약 768pt 폭 / 노트 768×1024pt)에서 2배율로 수행한다. // import SwiftUI import SwiftData import PencilKit import UIKit struct DiaryExportSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var context @Environment(\.diaryReadOnly) private var readOnly @Query private var diaryEntries: [DiaryEntry] @Query(sort: \Action.createdAt) private var allActionsQuery: [Action] @Query private var diaryTemplates: [DiaryTemplate] @AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = "" private enum SelectionMode: String, CaseIterable, Identifiable { case range, picks var id: String { rawValue } var label: String { switch self { case .range: return String(localized: "구간 지정") case .picks: return String(localized: "날짜 지정") } } } private enum ExportFormat: String, CaseIterable, Identifiable { case pdf, image var id: String { rawValue } var label: String { switch self { case .pdf: return "PDF" case .image: return String(localized: "이미지") } } } @State private var mode: SelectionMode = .range @State private var startDate: Date = Calendar.current.date(byAdding: .day, value: -6, to: .now)! @State private var endDate: Date = .now @State private var picks: Set = [] @State private var format: ExportFormat = .pdf @State private var isRendering = false @State private var resultURLs: [URL] = [] @State private var confirmingDelete = false private var math: DayMath { DayMath() } private var orderedActions: [Action] { LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw) } /// 선택 조건에 해당하고 내용이 있는 일기 (날짜순). /// 날짜 선택 UI에서 고른 달력일은 곧 일기 키의 달력일이다 — DiaryEntry.dayKey가 /// "해당 달력일의 자정"이므로 자정(startOfDay)을 키로 직접 쓴다. /// ⚠️ 자정 Date를 dayKey(for:)에 넣으면 하루 시작 시간이 자정보다 늦을 때 /// 전날로 밀려, 고른 날짜의 전날 일기가 내보내지는 버그가 있었다. private var targetEntries: [DiaryEntry] { let keys: Set switch mode { case .range: let startKey = math.calendar.startOfDay(for: min(startDate, endDate)) let endKey = math.calendar.startOfDay(for: max(startDate, endDate)) keys = Set(diaryEntries.map(\.dayKey).filter { $0 >= startKey && $0 <= endKey }) case .picks: keys = Set(picks.compactMap { components in math.calendar.date(from: components).map { math.calendar.startOfDay(for: $0) } }) } return diaryEntries .filter { keys.contains($0.dayKey) && $0.hasContent } .sorted { $0.dayKey < $1.dayKey } } var body: some View { NavigationStack { Form { Section("날짜 선택") { Picker("방식", selection: $mode) { ForEach(SelectionMode.allCases) { mode in Text(mode.label).tag(mode) } } .pickerStyle(.segmented) switch mode { case .range: DatePicker("부터", selection: $startDate, displayedComponents: .date) DatePicker("까지", selection: $endDate, displayedComponents: .date) case .picks: MultiDatePicker("날짜", selection: $picks) .frame(minHeight: 300) .tint(AppTheme.green) } Label { Text("일기를 쓴 날 \(targetEntries.count)일이 포함돼요") } icon: { Image(systemName: "pencil.and.scribble") } .font(.footnote) .foregroundStyle(targetEntries.isEmpty ? AnyShapeStyle(.secondary) : AnyShapeStyle(AppTheme.green)) } Section("형식") { Picker("형식", selection: $format) { ForEach(ExportFormat.allCases) { format in Text(format.label).tag(format) } } .pickerStyle(.segmented) Text(format == .pdf ? "일기 페이지들을 담은 PDF 문서 1개를 만들어요." : "날짜마다 페이지들을 세로로 이어 붙인 PNG 이미지를 만들어요.") .font(.caption) .foregroundStyle(.secondary) } Section { if isRendering { HStack { ProgressView() Text("만드는 중…") .foregroundStyle(.secondary) } } else if resultURLs.isEmpty { Button { export() } label: { Label("내보내기", systemImage: "square.and.arrow.up") .frame(maxWidth: .infinity) } .disabled(targetEntries.isEmpty) } else { ShareLink(items: resultURLs) { Label("파일 \(resultURLs.count)개 공유", systemImage: "square.and.arrow.up") .frame(maxWidth: .infinity) } Button("다시 만들기") { resultURLs = [] } .frame(maxWidth: .infinity) // 내보내기 후 삭제 (1.4) — 결과물을 먼저 저장하도록 공유 아래에만 노출. // 별도 Section 대신 같은 섹션의 분기 안에 두는 이유: 시트가 떠 있는 // 동안 뒤에 새 Section을 조건부 삽입하면 그려지지 않는 문제가 있었다 // (분기 교체는 섹션 구조가 안정적이라 안전). 열람 전용은 숨김 (§6.7) if !readOnly { Button(role: .destructive) { confirmingDelete = true } label: { Label("내보낸 날짜의 일기 삭제…", systemImage: "trash") .frame(maxWidth: .infinity) } } } } footer: { if !resultURLs.isEmpty && !readOnly { Text("삭제를 누르면 방금 내보낸 \(targetEntries.count)일의 일기(필기·사진·할 일 포함)를 앱에서 지워 저장 공간을 줄여요. iCloud 동기화를 켰다면 다른 기기에서도 함께 삭제돼요. 행동·기록·목표와 양식 라이브러리는 그대로 남아요.") } else { Text("결과물은 일기 상세와 같은 구성(요약 + 노트 페이지)으로 만들어져요.") } } } .confirmationDialog( "일기 \(targetEntries.count)일치를 삭제할까요?", isPresented: $confirmingDelete, titleVisibility: .visible ) { Button("삭제", role: .destructive) { deleteExported() } } message: { Text("먼저 공유로 파일을 저장했는지 확인해 주세요. 삭제는 되돌릴 수 없어요.") } .tint(AppTheme.green) .scrollContentBackground(.hidden) .background(AppTheme.background) .navigationTitle("일기 내보내기") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("닫기") { dismiss() } } } } .onAppear { #if DEBUG // 검증용: -diaryExportRun pdf|image → 자동 내보내기 후 Documents에 결과 복사 if let raw = UserDefaults.standard.string(forKey: "diaryExportRun") { format = raw == "image" ? .image : .pdf export() } #endif } // 조건이 바뀌면 이전 결과를 무효화 .onChange(of: mode) { resultURLs = [] } .onChange(of: startDate) { resultURLs = [] } .onChange(of: endDate) { resultURLs = [] } .onChange(of: picks) { resultURLs = [] } .onChange(of: format) { resultURLs = [] } } // MARK: 내보낸 날짜 삭제 (1.4) /// 방금 내보낸 일기들을 삭제 — cascade로 페이지·요소·할 일·기분 사진까지 정리되고, /// 양식(DiaryTemplate)은 라이브러리 개념이라 남긴다 (§6.7 양식 삭제 정책과 동일) private func deleteExported() { for entry in targetEntries { context.delete(entry) } DataChange.commit(context: context) dismiss() } // MARK: 렌더링 (코어는 DiaryExportRenderer — 데이터 정리·초기화 백업과 공유) private var renderer: DiaryExportRenderer { DiaryExportRenderer(context: context, orderedActions: orderedActions, templates: diaryTemplates, math: math) } private func export() { isRendering = true let entries = targetEntries Task { @MainActor in // 시트 UI가 스피너를 그릴 틈을 준다 (렌더 자체는 메인에서 수행) try? await Task.sleep(for: .milliseconds(60)) var urls: [URL] = [] switch format { case .pdf: if let url = renderer.pdfURL(entries: entries, fileSlug: fileSlug) { urls = [url] } case .image: urls = entries.compactMap { renderer.stitchedImageURL($0) } } resultURLs = urls isRendering = false #if DEBUG if UserDefaults.standard.string(forKey: "diaryExportRun") != nil, let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { for url in urls { let dest = docs.appendingPathComponent(url.lastPathComponent) try? FileManager.default.removeItem(at: dest) try? FileManager.default.copyItem(at: url, to: dest) } } // 검증용: -diaryExportDeleteTest YES → 내보내기 완료 후 '삭제' 버튼과 // 같은 가드·같은 경로로 삭제 실행 (E2E — 재실행 후 달력 마커 소멸로 판정) if UserDefaults.standard.bool(forKey: "diaryExportDeleteTest"), !urls.isEmpty, !readOnly { try? await Task.sleep(for: .seconds(1)) deleteExported() } #endif } } private var fileSlug: String { let entries = targetEntries // 파일명은 언어별 현지화 (ExportSnapshot.fileName과 같은 이유) guard let first = entries.first else { return String(localized: "일기") } if entries.count == 1 { return String(localized: "일기-\(DiaryExportRenderer.slugDate(first.dayKey))") } return String(localized: "일기-\(DiaryExportRenderer.slugDate(first.dayKey))~\(DiaryExportRenderer.slugDate(entries.last!.dayKey))") } } // MARK: - 일기 렌더 코어 (1.4에서 시트에서 추출 — 초기화 백업과 공유) /// 일기 → PDF/스티치 PNG 렌더러. 시트와 '데이터 정리·초기화'(삭제 전 백업)가 공유한다. /// 렌더 규칙은 기존과 동일: 화면(하루 정리)과 같은 스냅숏 구성, 라이트 트레잇 고정, /// 논리 크기(요약 768pt 폭 / 노트 768×1024pt) 2배율. @MainActor struct DiaryExportRenderer { let context: ModelContext let orderedActions: [Action] let templates: [DiaryTemplate] let math: DayMath static func slugDate(_ date: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" return formatter.string(from: date) } /// 렌더 대상 날짜(논리적 하루)에 겹치는 기록만 그때그때 조회 — 전량 @Query 로드 방지 private func fetchRecords(for entry: DiaryEntry) -> (sessions: [TimeSession], counts: [CountEntry]) { let range = math.dayRange(forKey: entry.dayKey) let lower = range.lowerBound let upper = range.upperBound let farFuture = Date.distantFuture let sessions = (try? context.fetch(FetchDescriptor( predicate: #Predicate { $0.startAt < upper && ($0.endAt ?? farFuture) > lower } ))) ?? [] let counts = (try? context.fetch(FetchDescriptor( predicate: #Predicate { $0.timestamp >= lower && $0.timestamp < upper } ))) ?? [] return (sessions, counts) } /// 한 일기의 모든 페이지를 이미지로 (요약 + 노트들) func renderPages(_ entry: DiaryEntry) -> [UIImage] { var images: [UIImage] = [] let records = fetchRecords(for: entry) var snapshot = ExportBuilder.history( dayKey: entry.dayKey, weeklyTimetable: false, sessions: records.sessions, entries: records.counts, orderedActions: orderedActions, excludedActionIDs: [], math: math, trimHours: false ) // 화면(하루 정리)과 동일하게 그날 숨긴 행동은 타임테이블에서만 제외 (기록 목록은 전체 유지). // 날짜별 재정의가 없으면 기기 기본값이 적용된다 (DiaryDefaults, 1.2 — 화면과 동일 규칙) let hidden = DiaryDefaults.effectiveHiddenActions(for: entry) if !hidden.isEmpty { let excluded = Set( orderedActions.filter { hidden.contains($0.uuid.uuidString) }.map(\.persistentModelID) ) if !excluded.isEmpty { snapshot = snapshot.replacingTimetableSections(with: ExportBuilder.history( dayKey: entry.dayKey, weeklyTimetable: false, sessions: records.sessions, entries: records.counts, orderedActions: orderedActions, excludedActionIDs: excluded, math: math, trimHours: false )) } } // 화면(하루 정리)과 동일하게 24시간 전체 + 선택한 캘린더 일정을 담는다 snapshot = snapshot.injectingCalendarEvents(DiaryCalendarEvents.timetableBlocks(for: entry, math: math)) let summary = DiaryPrintSummaryView(entry: entry, snapshot: snapshot) let summaryRenderer = ImageRenderer(content: summary) summaryRenderer.scale = 2 summaryRenderer.isOpaque = true if let image = summaryRenderer.uiImage { images.append(image) } for page in entry.pages where page.hasContent { let size = DiaryPageMetrics.size var drawingImage: UIImage? if !page.drawingData.isEmpty, let drawing = try? PKDrawing(data: page.drawingData) { // 인쇄 페이지는 라이트 고정(흰 종이)이라 획 색도 라이트 기준으로 못 박는다 — // PKDrawing.image는 호출 시점의 현재 트레잇을 따르므로, 고정하지 않으면 // 다크 모드에서 내보낼 때 획이 반전된 색(흰색)으로 렌더될 수 있다 UITraitCollection(userInterfaceStyle: .light).performAsCurrent { drawingImage = drawing.image(from: CGRect(origin: .zero, size: size), scale: 2) } } // 배경 양식도 화면과 동일하게 담는다 (양식이 삭제됐으면 화면처럼 빈 캔버스) var templateImage: UIImage? if !page.templateID.isEmpty, let template = templates.first(where: { $0.uuid.uuidString == page.templateID }) { templateImage = DiaryTemplateRenderer.image( for: template, pageIndex: page.templatePageIndex, scale: 2 ) } let view = DiaryPrintNotePageView(page: page, drawingImage: drawingImage, templateImage: templateImage) let renderer = ImageRenderer(content: view) renderer.scale = 2 renderer.isOpaque = true if let image = renderer.uiImage { images.append(image) } } return images } /// PDF 1개 — 일기 페이지마다 PDF 한 페이지 (페이지 크기는 내용 크기 그대로) func pdfURL(entries: [DiaryEntry], fileSlug: String) -> URL? { let url = FileManager.default.temporaryDirectory .appendingPathComponent(String(localized: "하루다님-\(fileSlug).pdf")) let bounds = CGRect(origin: .zero, size: DiaryPageMetrics.size) let renderer = UIGraphicsPDFRenderer(bounds: bounds) do { try renderer.writePDF(to: url) { pdf in for entry in entries { for image in renderPages(entry) { let pageWidth = DiaryPageMetrics.size.width let pageHeight = image.size.height * pageWidth / image.size.width let pageBounds = CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight) pdf.beginPage(withBounds: pageBounds, pageInfo: [:]) image.draw(in: pageBounds) } } } return url } catch { return nil } } /// 날짜 하나 → 페이지들을 세로로 이어 붙인 PNG func stitchedImageURL(_ entry: DiaryEntry) -> URL? { let images = renderPages(entry) guard !images.isEmpty else { return nil } let width = DiaryPageMetrics.size.width let gap: CGFloat = 20 let heights = images.map { $0.size.height * width / $0.size.width } let totalHeight = heights.reduce(0, +) + gap * CGFloat(images.count - 1) let rendererFormat = UIGraphicsImageRendererFormat() rendererFormat.scale = 2 let renderer = UIGraphicsImageRenderer( size: CGSize(width: width, height: totalHeight), format: rendererFormat ) let stitched = renderer.image { ctx in UIColor(AppTheme.background).setFill() ctx.fill(CGRect(x: 0, y: 0, width: width, height: totalHeight)) var y: CGFloat = 0 for (image, height) in zip(images, heights) { image.draw(in: CGRect(x: 0, y: y, width: width, height: height)) y += height + gap } } guard let data = stitched.pngData() else { return nil } let url = FileManager.default.temporaryDirectory .appendingPathComponent(String(localized: "하루다님-일기-\(Self.slugDate(entry.dayKey)).png")) try? data.write(to: url, options: .atomic) return url } } // MARK: - 인쇄용 요약 페이지 (768pt 고정 폭) struct DiaryPrintSummaryView: View { let entry: DiaryEntry let snapshot: ExportSnapshot private let width: CGFloat = 768 private var innerWidth: CGFloat { width - 40 - 28 } var body: some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 3) { Text(Format.fullDate(entry.dayKey)) .font(.system(size: 26, weight: .bold)) Text("하루 다님 일기") .font(.subheadline) .foregroundStyle(.secondary) } Spacer() if !entry.moodEmoji.isEmpty { Text(entry.moodEmoji) .font(.system(size: 44)) } if let data = entry.moodImageData, let image = UIImage(data: data) { Image(uiImage: image) .resizable() .scaledToFill() .frame(width: 64, height: 64) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } } if !entry.todos.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("오늘 할 일") .font(.subheadline.weight(.semibold)) ForEach(entry.todos, id: \.uuid) { todo in HStack(spacing: 8) { Image(systemName: todo.isDone ? "checkmark.circle.fill" : "circle") .foregroundStyle(todo.isDone ? AnyShapeStyle(AppTheme.green) : AnyShapeStyle(.secondary)) Text(todo.text) .font(.footnote) .strikethrough(todo.isDone, color: .secondary) .foregroundStyle(todo.isDone ? .secondary : .primary) } } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } if !snapshot.hero.isEmpty { ExportHeroRow(stats: snapshot.hero) } ForEach(Array(snapshot.sections.enumerated()), id: \.offset) { _, section in ExportSectionView(section: section, innerWidth: innerWidth) } } .padding(20) .frame(width: width, alignment: .leading) .background(AppTheme.background) .environment(\.colorScheme, .light) } } // MARK: - 인쇄용 노트 페이지 (768×1024 고정) struct DiaryPrintNotePageView: View { let page: DiaryPage let drawingImage: UIImage? var templateImage: UIImage? = nil var body: some View { ZStack { Rectangle().fill(AppTheme.surface) if page.lined, templateImage == nil { DiaryLinedPaper(spacing: page.lineSpacing) } if let templateImage { Image(uiImage: templateImage) .resizable() .scaledToFit() } ForEach(page.items, id: \.uuid) { item in DiaryItemView( item: item, pageSize: DiaryPageMetrics.size, arranging: false, selectedID: .constant(nil) ) } if let drawingImage { Image(uiImage: drawingImage) .resizable() .scaledToFit() } } .frame(width: DiaryPageMetrics.size.width, height: DiaryPageMetrics.size.height) .environment(\.colorScheme, .light) } }