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:
parent
8c93da5ff3
commit
a09d4b3da4
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user