- 아이폰 일기 개방: AppTab.phoneCases·radialOrder, '그리기' 토글(손가락)·가로 도구줄, 일기 잠금 설정 전 기기 - 건강 카드: DiarySection.health(목록 끝), DiaryHealthCard(지표 선택 공용화·타일 상속·맥 숨김), 인쇄 요약 동승 - 타임테이블 실구간: HealthIntervals 캐시(App Group 400일, CloudKit 금지), injectingHealthBlocks(수면 인디고·운동 주황), 필터 '건강 데이터' 토글+맥 안내 - 수면 표시 구간: SleepWindowPrefs(전체+요일별), 평문 sleep 키=유효 구간 값, 백필 서명 매핑 포함, 새 수면 다짐 기본값 시드 - 실측 수정: 일기 목표 카드 건강 다짐 값 단위, ja 건강 칩 말줄임 - 검증 61/20/27/10 ALL PASS·3종 빌드·카탈로그 0/0·시각 QA 10여 장(26.5/18.5/맥 분기) - 마케팅: 06-bubble 재촬영, 10-week→10-diary 교체, 아이패드 03 재촬영(3언어), whats-new/설명 갱신 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
589 lines
26 KiB
Swift
589 lines
26 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
|
||
@Environment(\.diaryReadOnly) private var readOnly
|
||
|
||
@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] = []
|
||
@State private var confirmingDelete = false
|
||
|
||
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)
|
||
// 내보내기 후 삭제 (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)
|
||
.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: 내보낸 날짜 삭제 (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
|
||
let entries = targetEntries
|
||
Task { @MainActor in
|
||
// 시트 UI가 스피너를 그릴 틈을 준다 (렌더 자체는 메인에서 수행)
|
||
try? await Task.sleep(for: .milliseconds(60))
|
||
var urls: [URL] = []
|
||
switch format {
|
||
case .pdf:
|
||
if let url = renderer.pdfURL(entries: entries, fileSlug: fileSlug) { urls = [url] }
|
||
case .image:
|
||
urls = entries.compactMap { renderer.stitchedImageURL($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)
|
||
}
|
||
}
|
||
// 검증용: -diaryExportDeleteTest YES → 내보내기 완료 후 '삭제' 버튼과
|
||
// 같은 가드·같은 경로로 삭제 실행 (E2E — 재실행 후 달력 마커 소멸로 판정)
|
||
if UserDefaults.standard.bool(forKey: "diaryExportDeleteTest"),
|
||
!urls.isEmpty, !readOnly {
|
||
try? await Task.sleep(for: .seconds(1))
|
||
deleteExported()
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
|
||
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: "일기-\(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 로드 방지
|
||
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)
|
||
}
|
||
|
||
/// 한 일기의 모든 페이지를 이미지로 (요약 + 노트들)
|
||
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
|
||
)
|
||
// 화면(하루 정리)과 동일하게 그날 숨긴 행동은 타임테이블에서만 제외 (기록 목록은 전체 유지).
|
||
// 날짜별 재정의가 없으면 기기 기본값이 적용된다 (DiaryDefaults, 1.2 — 화면과 동일 규칙)
|
||
let hidden = DiaryDefaults.effectiveHiddenActions(for: entry)
|
||
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시간 전체 + 선택한 캘린더 일정 + 건강 블록(1.5)을 담는다
|
||
snapshot = snapshot.injectingCalendarEvents(DiaryCalendarEvents.timetableBlocks(for: entry, math: math))
|
||
snapshot = snapshot.injectingHealthBlocks(DiaryHealthTimetable.blocks(dayKey: entry.dayKey, 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) {
|
||
// 인쇄 페이지는 라이트 고정(흰 종이)이라 획 색도 라이트 기준으로 못 박는다 —
|
||
// PKDrawing.image는 호출 시점의 현재 트레잇을 따르므로, 고정하지 않으면
|
||
// 다크 모드에서 내보낼 때 획이 반전된 색(흰색)으로 렌더될 수 있다
|
||
UITraitCollection(userInterfaceStyle: .light).performAsCurrent {
|
||
drawingImage = drawing.image(from: CGRect(origin: .zero, size: size), scale: 2)
|
||
}
|
||
}
|
||
// 배경 양식도 화면과 동일하게 담는다 (양식이 삭제됐으면 화면처럼 빈 캔버스)
|
||
var templateImage: UIImage?
|
||
if !page.templateID.isEmpty,
|
||
let template = templates.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 한 페이지 (페이지 크기는 내용 크기 그대로)
|
||
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)
|
||
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
|
||
func stitchedImageURL(_ 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: "하루다님-일기-\(Self.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)
|
||
}
|
||
DiaryPrintHealthRow(dayKey: entry.dayKey)
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// 인쇄 요약의 건강 값 줄 (1.5) — 화면 카드(DiaryHealthCard)와 같은 지표 선택·수면 구간 규칙.
|
||
/// 값이 하나도 없거나(맥·데이터 없는 기기) 첫 화면 구성에서 건강 카드를 숨겼으면 그리지 않는다
|
||
struct DiaryPrintHealthRow: View {
|
||
let dayKey: Date
|
||
|
||
private var entries: [(metric: HealthMetric, value: Double)] {
|
||
guard HealthDataStore.isAvailable else { return [] }
|
||
let hiddenRaw = UserDefaults.standard.string(forKey: DiarySectionConfig.hiddenKey) ?? ""
|
||
guard !DiarySectionConfig.hidden(from: hiddenRaw).contains(.health) else { return [] }
|
||
let diaryRaw = UserDefaults.standard.string(forKey: "diary.healthMetrics") ?? ""
|
||
let tileRaw = AppGroup.defaults.string(forKey: LocalPrefsKeys.healthMetrics) ?? ""
|
||
let metrics = HealthMetric.selectedList(raw: diaryRaw.isEmpty ? tileRaw : diaryRaw)
|
||
return metrics.compactMap { metric in
|
||
let key = metric == .sleep
|
||
? "sleep@\(SleepWindowPrefs.spec(forDayKey: dayKey))"
|
||
: metric.rawValue
|
||
guard let value = HealthCache.value(key, dayKey: dayKey) else { return nil }
|
||
return (metric, value)
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
let list = entries
|
||
if !list.isEmpty {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "heart.fill")
|
||
.font(.caption)
|
||
.foregroundStyle(.pink)
|
||
Text("건강 데이터")
|
||
.font(.subheadline.weight(.semibold))
|
||
}
|
||
HStack(spacing: 14) {
|
||
ForEach(list, id: \.metric) { item in
|
||
HStack(spacing: 5) {
|
||
Image(safeSymbol: item.metric.symbolName)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(.pink)
|
||
Text(item.metric.name)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
Text(item.metric.valueLabel(item.value))
|
||
.font(.footnote.weight(.semibold).monospacedDigit())
|
||
}
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
.padding(14)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|