feat(diary): 내보내기 후 삭제 + 렌더 파이프라인 DiaryExportRenderer로 추출

- 내보내기 완료 화면(공유·다시 만들기 아래)에 '내보낸 날짜의 일기 삭제…' — 확인 다이얼로그, iCloud 전 기기 삭제·양식 보존 안내, 열람 전용 숨김
- 별도 Section 조건부 삽입은 시트 표시 중 렌더되지 않아 기존 섹션 분기 안 배치로 확정
- PDF/스티치 PNG 렌더 코어를 DiaryExportRenderer로 추출 — 데이터 정리·초기화 백업이 재사용 (동작·규칙 불변, PDF 산출 회귀 확인)
- -diaryExportDeleteTest E2E: 내보내기→삭제→재실행 후 달력 마커 소멸 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
This commit is contained in:
songyc macbook 2026-08-11 16:59:57 +09:00
parent 8c93da5ff3
commit a09d4b3da4

View File

@ -17,6 +17,7 @@ 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]
@ -52,6 +53,7 @@ struct DiaryExportSheet: View {
@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() }
@ -149,11 +151,36 @@ struct DiaryExportSheet: View {
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)
@ -182,7 +209,24 @@ struct DiaryExportSheet: View {
.onChange(of: format) { resultURLs = [] }
}
// MARK:
// 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
@ -193,9 +237,9 @@ struct DiaryExportSheet: View {
var urls: [URL] = []
switch format {
case .pdf:
if let url = renderPDF(entries) { urls = [url] }
if let url = renderer.pdfURL(entries: entries, fileSlug: fileSlug) { urls = [url] }
case .image:
urls = entries.compactMap { renderStitchedImage($0) }
urls = entries.compactMap { renderer.stitchedImageURL($0) }
}
resultURLs = urls
isRendering = false
@ -208,26 +252,45 @@ struct DiaryExportSheet: View {
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 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))")
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
@MainActor
private func fetchRecords(for entry: DiaryEntry) -> (sessions: [TimeSession], counts: [CountEntry]) {
let range = math.dayRange(forKey: entry.dayKey)
let lower = range.lowerBound
@ -243,8 +306,7 @@ struct DiaryExportSheet: View {
}
/// ( + )
@MainActor
private func renderPages(_ entry: DiaryEntry) -> [UIImage] {
func renderPages(_ entry: DiaryEntry) -> [UIImage] {
var images: [UIImage] = []
let records = fetchRecords(for: entry)
var snapshot = ExportBuilder.history(
@ -292,7 +354,7 @@ struct DiaryExportSheet: View {
// ( )
var templateImage: UIImage?
if !page.templateID.isEmpty,
let template = diaryTemplates.first(where: { $0.uuid.uuidString == page.templateID }) {
let template = templates.first(where: { $0.uuid.uuidString == page.templateID }) {
templateImage = DiaryTemplateRenderer.image(
for: template, pageIndex: page.templatePageIndex, scale: 2
)
@ -310,8 +372,7 @@ struct DiaryExportSheet: View {
}
/// PDF 1 PDF ( )
@MainActor
private func renderPDF(_ entries: [DiaryEntry]) -> URL? {
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)
@ -335,8 +396,7 @@ struct DiaryExportSheet: View {
}
/// PNG
@MainActor
private func renderStitchedImage(_ entry: DiaryEntry) -> URL? {
func stitchedImageURL(_ entry: DiaryEntry) -> URL? {
let images = renderPages(entry)
guard !images.isEmpty else { return nil }
let width = DiaryPageMetrics.size.width
@ -360,7 +420,7 @@ struct DiaryExportSheet: View {
}
guard let data = stitched.pngData() else { return nil }
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(String(localized: "하루다님-일기-\(slugDate(entry.dayKey)).png"))
.appendingPathComponent(String(localized: "하루다님-일기-\(Self.slugDate(entry.dayKey)).png"))
try? data.write(to: url, options: .atomic)
return url
}