From a09d4b3da489299127ef032d4fb392e311205a12 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Tue, 11 Aug 2026 16:59:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(diary):=20=EB=82=B4=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=9B=84=20=EC=82=AD=EC=A0=9C=20+=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20Dia?= =?UTF-8?q?ryExportRenderer=EB=A1=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 내보내기 완료 화면(공유·다시 만들기 아래)에 '내보낸 날짜의 일기 삭제…' — 확인 다이얼로그, iCloud 전 기기 삭제·양식 보존 안내, 열람 전용 숨김 - 별도 Section 조건부 삽입은 시트 표시 중 렌더되지 않아 기존 섹션 분기 안 배치로 확정 - PDF/스티치 PNG 렌더 코어를 DiaryExportRenderer로 추출 — 데이터 정리·초기화 백업이 재사용 (동작·규칙 불변, PDF 산출 회귀 확인) - -diaryExportDeleteTest E2E: 내보내기→삭제→재실행 후 달력 마커 소멸 확인 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf --- .../IOS/Views/DiaryExportSheet.swift | 102 ++++++++++++++---- 1 file changed, 81 insertions(+), 21 deletions(-) diff --git a/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift b/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift index e7282f3..830c769 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift @@ -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: { - Text("결과물은 일기 상세와 같은 구성(요약 + 노트 페이지)으로 만들어져요.") + 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 }