mycode/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift
songyc macbook bdb97e3c9f feat(diary): imported page templates — PDF/photo backgrounds with per-page picker
일기 노트 페이지 양식(속지) 기능 (합의된 설계 그대로):

- 모델: DiaryTemplate 신설(원본 externalStorage — 여러 쪽 PDF도
  통째 1개만 저장 + pageCount) + DiaryPage.templateID/
  templatePageIndex(기본값 있는 새 필드, uuid 문자열 참조 —
  CloudKit 규칙·동기화 순서에 안전). 스키마에 등록.
- 양식 관리: 달력 툴바에서 진입 — PDF(파일, ≤20MB)·사진 가져오기,
  이름 변경·삭제(사용 중 페이지 수 경고 → 빈 캔버스 폴백, 필기 보존),
  첫 쪽 썸네일 저장. 쪽 상한 24쪽(초과분 안내).
- 페이지 추가: 양식이 있으면 [빈 캔버스/줄 노트/내 양식] 선택 시트,
  여러 쪽 PDF는 2단계 쪽 썸네일 선택(지연 생성). 양식이 없으면
  기존처럼 즉시 추가 — 흔한 경로의 반응성 유지.
- 렌더: 페이지 논리 좌표(768×1024)에 aspect-fit — 필기 좌표계 불변.
  PDF는 벡터 원본을 보존하고 줌 종료 시 현재 배율로 백그라운드
  재래스터(펜슬 캔버스 선명도 훅에 함께 연결 — 확대해도 흐림 없음).
  이미지는 가져올 때 긴 변 3072px 리샘플(필기 래스터 상한 3×와 동일).
  양식 페이지는 줄 노트 토글 숨김(상호 배타), 양식만 깔린 페이지도
  작성함 취급(hasContent). PencilKit 캔버스 코드는 건드리지 않음.
- 내보내기(PDF/PNG)에도 양식 배경 동일 반영.

검증(iPad 시뮬레이터, -diarySeedTemplate 등 신규 DEBUG 인자):
양식 관리·페이지 선택 시트·양식 페이지 렌더(2× 프로그램 줌에서
점 노트 선명) 스크린샷 + 이미지 내보내기 산출물에 양식 포함 확인.
Debug·Store 빌드 성공, 신규 문자열 22종 ko/en/ja(missing 0).
도움말 '나만의 양식(속지)' 항목 추가, CLAUDE.md §3·§6.7·§14 갱신.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-14 19:10:50 +09:00

446 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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<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)
}
/// ()
private var targetEntries: [DiaryEntry] {
let keys: Set<Date>
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)
}
}