// // 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 @Query private var diaryEntries: [DiaryEntry] @Query private var sessions: [TimeSession] @Query private var countEntries: [CountEntry] @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] = [] private var math: DayMath { DayMath() } private var orderedActions: [Action] { LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw) } /// 선택 조건에 해당하고 내용이 있는 일기 (날짜순) private var targetEntries: [DiaryEntry] { let keys: Set switch mode { case .range: let startKey = math.dayKey(for: min(startDate, endDate)) let endKey = math.dayKey(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.dayKey(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) } } footer: { 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: 렌더링 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 = renderPDF(entries) { urls = [url] } case .image: urls = entries.compactMap { renderStitchedImage($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) } } #endif } } private func slugDate(_ date: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" return formatter.string(from: date) } private var fileSlug: String { let entries = targetEntries guard let first = entries.first else { return "일기" } if entries.count == 1 { return "일기-\(slugDate(first.dayKey))" } return "일기-\(slugDate(first.dayKey))~\(slugDate(entries.last!.dayKey))" } /// 한 일기의 모든 페이지를 이미지로 (요약 + 노트들) @MainActor private func renderPages(_ entry: DiaryEntry) -> [UIImage] { var images: [UIImage] = [] var snapshot = ExportBuilder.history( dayKey: entry.dayKey, weeklyTimetable: false, sessions: sessions, entries: countEntries, orderedActions: orderedActions, excludedActionIDs: [], math: math, trimHours: false ) // 화면(하루 정리)과 동일하게 그날 숨긴 행동은 타임테이블에서만 제외 (기록 목록은 전체 유지) let hidden = Set(entry.hiddenActionIDs) 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: sessions, entries: countEntries, 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) { drawingImage = drawing.image(from: CGRect(origin: .zero, size: size), scale: 2) } // 배경 양식도 화면과 동일하게 담는다 (양식이 삭제됐으면 화면처럼 빈 캔버스) var templateImage: UIImage? if !page.templateID.isEmpty, let template = diaryTemplates.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 한 페이지 (페이지 크기는 내용 크기 그대로) @MainActor private func renderPDF(_ entries: [DiaryEntry]) -> URL? { let url = FileManager.default.temporaryDirectory .appendingPathComponent("하루다님-\(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 @MainActor private func renderStitchedImage(_ 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("하루다님-일기-\(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) } }