mycode/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift
songyc macbook 0a55b45515 feat(1.5-p6): 소킹 중 추가 5건 — 아이폰 일기 탭·일기 건강 카드·타임테이블 수면/운동 실구간·수면 표시 구간(요일별)·맥 필터 안내, 빌드 4
- 아이폰 일기 개방: 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
2026-08-22 21:56:57 +09:00

589 lines
26 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
@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)
}
}