- 내보내기 파일명을 언어별로 현지화 — 수신 환경(PC·클라우드·메일)이 한글 파일명을 지원하지 않을 수 있음 (사용자 결정). ko는 기존 그대로("하루다님-기록-…"), en/ja는 라틴 접두 "HaruDanim-" + 그 언어의 종류 단어 (History/記録, Diary/日記, Stats/統計, Actions/アクション 등)
- 대상 전부: 기록·통계 리포트 PNG(ExportSnapshot.fileName + 슬러그 3종), 일기 PDF·스티치 PNG(슬러그 3종 포함), CSV 5종(행동·목표·다짐은 기존 키 재사용, 시간기록·횟수기록 신규 키). CSV 헤더는 데이터로 보고 한국어 유지
- 카탈로그 신규 11키 en/ja 완비(751키 missing/stale 0), CLAUDE.md §7에 규칙 기재
- 검증(시뮬레이터 실파일명): en CSV 5종 "HaruDanim-Actions/TimeRecords/CountRecords/Goals/Quest.csv", en 리포트 "HaruDanim-History-2026-07-16.png", iPad en 일기 "HaruDanim-Diary-2026-07-15~2026-07-16.pdf"(기간 슬러그), ko 회귀 "하루다님-행동.csv" 등 5종 그대로. Debug·Store 빌드 성공
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
466 lines
20 KiB
Swift
466 lines
20 KiB
Swift
//
|
||
// 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(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<DateComponents> = []
|
||
@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)
|
||
}
|
||
|
||
/// 선택 조건에 해당하고 내용이 있는 일기 (날짜순).
|
||
/// 날짜 선택 UI에서 고른 달력일은 곧 일기 키의 달력일이다 — DiaryEntry.dayKey가
|
||
/// "해당 달력일의 자정"이므로 자정(startOfDay)을 키로 직접 쓴다.
|
||
/// ⚠️ 자정 Date를 dayKey(for:)에 넣으면 하루 시작 시간이 자정보다 늦을 때
|
||
/// 전날로 밀려, 고른 날짜의 전날 일기가 내보내지는 버그가 있었다.
|
||
private var targetEntries: [DiaryEntry] {
|
||
let keys: Set<Date>
|
||
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)
|
||
}
|
||
} 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
|
||
// 파일명은 언어별 현지화 (ExportSnapshot.fileName과 같은 이유)
|
||
guard let first = entries.first else { return String(localized: "일기") }
|
||
if entries.count == 1 { return String(localized: "일기-\(slugDate(first.dayKey))") }
|
||
return String(localized: "일기-\(slugDate(first.dayKey))~\(slugDate(entries.last!.dayKey))")
|
||
}
|
||
|
||
/// 렌더 대상 날짜(논리적 하루)에 겹치는 기록만 그때그때 조회 — 전량 @Query 로드 방지
|
||
@MainActor
|
||
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<TimeSession>(
|
||
predicate: #Predicate { $0.startAt < upper && ($0.endAt ?? farFuture) > lower }
|
||
))) ?? []
|
||
let counts = (try? context.fetch(FetchDescriptor<CountEntry>(
|
||
predicate: #Predicate { $0.timestamp >= lower && $0.timestamp < upper }
|
||
))) ?? []
|
||
return (sessions, counts)
|
||
}
|
||
|
||
/// 한 일기의 모든 페이지를 이미지로 (요약 + 노트들)
|
||
@MainActor
|
||
private 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
|
||
)
|
||
// 화면(하루 정리)과 동일하게 그날 숨긴 행동은 타임테이블에서만 제외 (기록 목록은 전체 유지)
|
||
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: 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) {
|
||
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(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
|
||
@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(String(localized: "하루다님-일기-\(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)
|
||
}
|
||
}
|