// // DiaryView.swift // Haru_Danim // // 일기 탭 (iPad 전용, 프리미엄 — CLAUDE.md §7.2 확장 기능). // 구조: // - DiaryRootView: 월 달력. 일기를 쓴 날짜에 마커(기분 이모지 또는 초록 점)가 붙고, // 날짜를 누르면 그 날짜의 일기로 이동. 툴바에서 기간/복수 날짜 내보내기. // - DiaryDetailView: 좌우로 넘기는 페이지 구성. 첫 페이지 = 요약(오늘 날짜·기분·할 일· // 타임테이블·통계), 이후 페이지 = 자유 필기 노트(DiaryNotePage.swift), 마지막 = 페이지 추가. // - DiarySummaryPage: 그날의 기록/통계는 이미지 내보내기와 같은 스냅숏(ExportBuilder)을 // 재사용해 화면과 내보내기 결과가 항상 일치한다. // import SwiftUI import SwiftData import PhotosUI import ImageIO #if DEBUG import PencilKit // 시드 필기 획 생성용 (검증 전용) #endif // MARK: - 읽기 전용 모드 (프리미엄 만료 후 열람 허용) /// 프리미엄이 끝나도 이미 쓴 일기는 볼 수 있어야 한다 — 이 값이 true면 /// 일기 화면 전체가 열람 전용이 된다 (새 엔트리 생성·편집 UI 비활성). private struct DiaryReadOnlyKey: EnvironmentKey { static let defaultValue = false } extension EnvironmentValues { var diaryReadOnly: Bool { get { self[DiaryReadOnlyKey.self] } set { self[DiaryReadOnlyKey.self] = newValue } } } // MARK: - 루트 (달력) struct DiaryRootView: View { @Environment(\.modelContext) private var context @Query private var diaryEntries: [DiaryEntry] @State private var premium = PremiumManager.shared /// 표시 중인 달의 아무 날짜 키 (그 달의 1일로 정규화해 사용) @State private var monthAnchor: Date = DayMath().dayKey(for: .now) @State private var showingExport = false @State private var showingTemplates = false @State private var showingPremiumSheet = false private var math: DayMath { DayMath() } /// 만료 후에도 이미 쓴 일기가 있으면 열람 전용으로 달력을 연다 private var isReadOnly: Bool { !premium.isPremium && diaryEntries.contains { $0.hasContent } } var body: some View { Group { if !premium.isPremium && !isReadOnly { DiaryLockedView() } else { #if DEBUG // 검증용: -diaryOpenToday YES → 달력 없이 오늘 일기를 바로 렌더 if UserDefaults.standard.bool(forKey: "diaryOpenToday") { DiaryDetailView(dayKey: math.dayKey(for: .now)) } else { calendar } #else calendar #endif } } .environment(\.diaryReadOnly, isReadOnly) .background(AppTheme.background) .navigationTitle("일기") .navigationBarTitleDisplayMode(.inline) .onAppear { #if DEBUG // 검증용: -diarySeed YES → 오늘 일기(기분·할 일·노트 페이지) 데모 생성 if UserDefaults.standard.bool(forKey: "diarySeed") { seedToday() } // 검증용: -diarySeedTemplate YES → 2쪽짜리 샘플 PDF 양식 생성 (파일 가져오기 없이 검증) if UserDefaults.standard.bool(forKey: "diarySeedTemplate") { seedSampleTemplate() } // 검증용: -diarySeedPhoto YES → 오늘(이모지+사진)·어제(사진만) 기분 사진 시드 if UserDefaults.standard.bool(forKey: "diarySeedPhoto") { seedMoodPhotos() } #endif } } private var calendar: some View { ScrollView { VStack(spacing: 14) { if isReadOnly { readOnlyBanner } monthHeader DiaryCalendarGrid( monthAnchor: monthAnchor, entriesByDay: entriesByDay, math: math, readOnly: isReadOnly ) monthSummary } .padding(20) .frame(maxWidth: 760) .frame(maxWidth: .infinity) } .navigationDestination(for: Date.self) { key in DiaryDetailView(dayKey: key) } .toolbar { // 양식은 새 페이지 작성용이므로 열람 전용(프리미엄 만료)에서는 관리 진입도 숨긴다 if !isReadOnly { ToolbarItem(placement: .topBarTrailing) { Button { showingTemplates = true } label: { Image(systemName: "doc.text.image") } .accessibilityLabel(Text("양식 관리")) } } ToolbarItem(placement: .topBarTrailing) { Button { showingExport = true } label: { Image(systemName: "square.and.arrow.up") } .accessibilityLabel(Text("일기 내보내기")) } } .sheet(isPresented: $showingExport) { DiaryExportSheet() } .sheet(isPresented: $showingTemplates) { DiaryTemplateManagerView() } .sheet(isPresented: $showingPremiumSheet) { PremiumSheetView() } .onAppear { // 일기 상세는 진입만 해도 엔트리를 만들므로(loadEntry), 달력으로 돌아올 때 // 아무 흔적도 남기지 않은 빈 엔트리를 정리한다 (CloudKit에 빈 레코드 누적 방지) cleanupEmptyEntries() #if DEBUG if UserDefaults.standard.bool(forKey: "diaryShowExport") { showingExport = true } // 검증용: -diaryShowTemplates YES → 양식 관리 시트 바로 표시 if UserDefaults.standard.bool(forKey: "diaryShowTemplates") { showingTemplates = true } #endif } } /// 완전히 빈 엔트리만 삭제. hasContent(기분·할 일·내용 있는 페이지)뿐 아니라 /// 달력 마커에는 안 잡혀도 사용자에게 의미 있는 캘린더 일정·타임테이블 필터·목표 필터 /// 선택이 있으면 보존하고, (아직 내용 없는) 노트 페이지를 만들어 둔 날도 보존한다. private func cleanupEmptyEntries() { var changed = false for entry in diaryEntries where !entry.hasContent && entry.calendarEventIDs.isEmpty && entry.hiddenActionIDs.isEmpty && entry.hiddenGoalIDs.isEmpty && (entry.pagesStorage ?? []).isEmpty { context.delete(entry) changed = true } if changed { try? context.save() } } /// 작성된 일기(내용 있는 것만)를 하루 키로 색인 private var entriesByDay: [Date: DiaryEntry] { var result: [Date: DiaryEntry] = [:] for entry in diaryEntries where entry.hasContent { result[entry.dayKey] = entry } return result } /// 만료 열람 모드 안내 — 이미 쓴 일기는 계속 볼 수 있고, 새 작성만 프리미엄이 필요함을 알린다 private var readOnlyBanner: some View { HStack(spacing: 10) { Image(systemName: "book.closed") .foregroundStyle(AppTheme.yellow) Text("프리미엄 기간이 끝나 열람만 가능해요. 이미 쓴 일기는 계속 볼 수 있어요.") .font(.footnote) .foregroundStyle(.secondary) Spacer() Button("프리미엄") { showingPremiumSheet = true } .font(.footnote.weight(.semibold)) .buttonStyle(.bordered) .buttonBorderShape(.capsule) .tint(AppTheme.green) } .padding(12) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) } // MARK: 월 이동 헤더 private var monthHeader: some View { HStack { Button { moveMonth(-1) } label: { Image(systemName: "chevron.left") .frame(width: 44, height: 36) .contentShape(Rectangle()) } .accessibilityLabel(Text("이전 달")) Spacer() Text(monthAnchor.formatted(.dateTime.year().month())) .font(.title3.weight(.bold)) Spacer() Button { moveMonth(1) } label: { Image(systemName: "chevron.right") .frame(width: 44, height: 36) .contentShape(Rectangle()) } .accessibilityLabel(Text("다음 달")) Button("이번 달") { monthAnchor = math.dayKey(for: .now) } .font(.caption) .buttonStyle(.bordered) .buttonBorderShape(.capsule) } .tint(AppTheme.green) } private func moveMonth(_ delta: Int) { monthAnchor = math.calendar.date(byAdding: .month, value: delta, to: monthAnchor)! } private var monthSummary: some View { // monthAnchor는 자정 '키'라 monthRange(containing:)에 그대로 넣으면 하루 시작 시간이 // 자정보다 늦을 때 전날(= 매달 1일이면 이전 달)로 밀린다 — 키를 실제 시각으로 변환해 전달 let range = math.monthRange(containing: math.dayRange(forKey: monthAnchor).lowerBound) let count = entriesByDay.keys.filter { range.contains(math.dayRange(forKey: $0).lowerBound) }.count return HStack(spacing: 6) { Image(systemName: "pencil.and.scribble") .font(.caption) Text("이번 달에 쓴 일기 \(count)편") .font(.caption.weight(.medium)) } .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } #if DEBUG private func seedToday() { let key = math.dayKey(for: .now) let existing = diaryEntries.first { $0.dayKey == key } guard existing == nil else { return } let entry = DiaryEntry(dayKey: key) entry.moodEmoji = "🙂" context.insert(entry) let todoTexts = [String(localized: "물 2리터 마시기"), String(localized: "독서 30분"), String(localized: "저녁 달리기")] for (index, text) in todoTexts.enumerated() { let todo = DiaryTodo(text: text, sortOrder: index) todo.isDone = index == 0 todo.entry = entry context.insert(todo) } let page = DiaryPage(index: 0) page.lined = true page.entry = entry context.insert(page) let rect = DiaryPageItem(kind: .rectangle) rect.centerX = 0.3; rect.centerY = 0.25; rect.widthRatio = 0.35 rect.page = page context.insert(rect) let arrow = DiaryPageItem(kind: .arrow) arrow.colorHex = "#D9A621" arrow.centerX = 0.62; arrow.centerY = 0.4; arrow.widthRatio = 0.3 arrow.page = page context.insert(arrow) let textBox = DiaryPageItem(kind: .text) textBox.text = String(localized: "오늘은 여기까지!\n내일은 더 일찍 시작하자.") textBox.colorHex = "#3A3A3A" textBox.centerX = 0.42; textBox.centerY = 0.62; textBox.widthRatio = 0.5 textBox.page = page context.insert(textBox) // 펜슬 획 시드 — 확대 시 획이 유지·선명한지 검증용 (물결 곡선 2줄) var strokes: [PKStroke] = [] for row in 0..<2 { let baseY = 780 + CGFloat(row) * 90 let points = (0...60).map { i -> PKStrokePoint in let t = CGFloat(i) / 60 return PKStrokePoint( location: CGPoint(x: 120 + t * 520, y: baseY + sin(t * .pi * 5) * 32), timeOffset: TimeInterval(i) * 0.01, size: CGSize(width: 5, height: 5), opacity: 1, force: 1, azimuth: 0, altitude: .pi / 2 ) } strokes.append(PKStroke( ink: PKInk(.pen, color: row == 0 ? .black : .systemBlue), path: PKStrokePath(controlPoints: points, creationDate: .now) )) } page.drawingData = PKDrawing(strokes: strokes).dataRepresentation() try? context.save() } /// 검증용 샘플 양식: 1쪽 = 격자, 2쪽 = 점 노트 (코드로 그린 2쪽짜리 PDF) /// 달력 사진 백레이어 검증용 — 오늘(이모지 유지)·어제(이모지 없음, 연필 마커)에 /// 그라데이션 샘플 사진을 붙인다. 실제 첨부와 같은 경로(diaryCompressed)로 저장. private func seedMoodPhotos() { let hues: [UIColor] = [ UIColor(red: 0.98, green: 0.72, blue: 0.40, alpha: 1), // 오늘: 노을 UIColor(red: 0.35, green: 0.62, blue: 0.85, alpha: 1), // 어제: 바다 ] for (offset, base) in hues.enumerated() { guard let day = math.calendar.date(byAdding: .day, value: -offset, to: math.dayKey(for: .now)) else { continue } let entry: DiaryEntry if let existing = diaryEntries.first(where: { $0.dayKey == day }) { entry = existing } else { entry = DiaryEntry(dayKey: day) context.insert(entry) } guard entry.moodImageData == nil else { continue } if offset == 1 { entry.moodEmoji = "" } let size = CGSize(width: 800, height: 600) let image = UIGraphicsImageRenderer(size: size).image { ctx in let cg = ctx.cgContext let colors = [base.cgColor, base.withAlphaComponent(0.35).cgColor] as CFArray if let gradient = CGGradient(colorsSpace: nil, colors: colors, locations: [0, 1]) { cg.drawLinearGradient(gradient, start: .zero, end: CGPoint(x: 0, y: size.height), options: []) } cg.setFillColor(UIColor.white.withAlphaComponent(0.85).cgColor) cg.fillEllipse(in: CGRect(x: size.width * 0.62, y: size.height * 0.18, width: 130, height: 130)) } entry.moodImageData = image.diaryCompressed() } try? context.save() } private func seedSampleTemplate() { let count = (try? context.fetchCount(FetchDescriptor())) ?? 0 guard count == 0 else { return } let bounds = CGRect(origin: .zero, size: DiaryPageMetrics.size) let data = UIGraphicsPDFRenderer(bounds: bounds).pdfData { pdf in for pageNo in 1...2 { pdf.beginPage() let cg = pdf.cgContext cg.setStrokeColor(UIColor.systemTeal.withAlphaComponent(0.45).cgColor) cg.setFillColor(UIColor.systemTeal.withAlphaComponent(0.45).cgColor) cg.setLineWidth(0.7) if pageNo == 1 { // 격자 var x: CGFloat = 32 while x < bounds.width - 24 { cg.move(to: CGPoint(x: x, y: 60)) cg.addLine(to: CGPoint(x: x, y: bounds.height - 40)) x += 32 } var y: CGFloat = 60 while y < bounds.height - 32 { cg.move(to: CGPoint(x: 32, y: y)) cg.addLine(to: CGPoint(x: bounds.width - 32, y: y)) y += 32 } cg.strokePath() } else { // 점 노트 var y: CGFloat = 60 while y < bounds.height - 32 { var x: CGFloat = 32 while x < bounds.width - 24 { cg.fillEllipse(in: CGRect(x: x - 1.5, y: y - 1.5, width: 3, height: 3)) x += 32 } y += 32 } } let title = "샘플 양식 \(pageNo)쪽" as NSString title.draw(at: CGPoint(x: 32, y: 24), withAttributes: [ .font: UIFont.boldSystemFont(ofSize: 18), .foregroundColor: UIColor.systemTeal, ]) } } let template = DiaryTemplate(name: "샘플 양식", kind: .pdf) template.data = data template.pageCount = 2 template.thumbnailData = DiaryTemplateRenderer.thumbnailData(kind: .pdf, data: data) context.insert(template) try? context.save() } #endif } // MARK: - 프리미엄 잠금 안내 struct DiaryLockedView: View { @State private var showingPremiumSheet = false var body: some View { VStack(spacing: 12) { Image(systemName: "crown.fill") .font(.system(size: 40)) .foregroundStyle(AppTheme.yellow) Text("일기는 프리미엄 기능이에요") .font(.title3.weight(.semibold)) Text("하루의 기록·통계와 함께 애플 펜슬로 쓰는\n나만의 다이어리를 만들 수 있어요.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) Button { showingPremiumSheet = true } label: { Label("프리미엄 알아보기", systemImage: "crown") .padding(.horizontal, 6) } .buttonStyle(.borderedProminent) .buttonBorderShape(.capsule) .tint(AppTheme.green) .padding(.top, 6) } .frame(maxWidth: .infinity, maxHeight: .infinity) .sheet(isPresented: $showingPremiumSheet) { PremiumSheetView() } } } // MARK: - 달력 그리드 /// 달력 셀용 기분 사진 썸네일 캐시 — 원본(≤900px JPEG)을 셀마다 디코드하면 /// 한 달치 렌더가 무거워지므로 ImageIO로 작게(≤220px) 뽑아 NSCache에 둔다. /// 키에 데이터 길이를 포함해 사진을 바꾸면 자연히 새로 만든다. @MainActor private enum DiaryMoodThumbs { private static let cache = NSCache() static func thumbnail(for entry: DiaryEntry) -> UIImage? { guard let data = entry.moodImageData else { return nil } let key = "\(entry.dayKey.timeIntervalSinceReferenceDate)-\(data.count)" as NSString if let hit = cache.object(forKey: key) { return hit } let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceThumbnailMaxPixelSize: 220, kCGImageSourceCreateThumbnailWithTransform: true, ] guard let source = CGImageSourceCreateWithData(data as CFData, nil), let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil } let image = UIImage(cgImage: cg) cache.setObject(image, forKey: key) return image } } private struct DiaryCalendarGrid: View { let monthAnchor: Date let entriesByDay: [Date: DiaryEntry] let math: DayMath /// 열람 전용이면 일기가 있는 날짜만 열 수 있다 (빈 날짜는 새 작성이 되므로 비활성) var readOnly = false private var calendar: Calendar { math.calendar } /// 주 시작 요일 설정을 따르는 요일 헤더 순서 private var weekdayOrder: [Int] { (0..<7).map { (calendar.firstWeekday - 1 + $0) % 7 + 1 } } /// 이 달 그리드 셀 (앞쪽 빈칸은 nil) private var cells: [Date?] { // monthAnchor(자정 키)를 실제 시각으로 변환해 전달 — monthSummary와 동일한 이유 let range = math.monthRange(containing: math.dayRange(forKey: monthAnchor).lowerBound) let keys = math.dayKeys(in: range) guard let first = keys.first else { return [] } let firstWeekday = calendar.component(.weekday, from: first) let leading = (firstWeekday - calendar.firstWeekday + 7) % 7 return Array(repeating: nil, count: leading) + keys.map { Optional($0) } } var body: some View { VStack(spacing: 6) { HStack(spacing: 0) { ForEach(weekdayOrder, id: \.self) { weekday in Text(Format.weekdayShort(weekday)) .font(.caption.weight(.semibold)) .foregroundStyle(weekday == 1 ? Color.red.opacity(0.7) : .secondary) .frame(maxWidth: .infinity) } } LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7), spacing: 4) { ForEach(Array(cells.enumerated()), id: \.offset) { _, key in if let key { if readOnly && entriesByDay[key] == nil { dayCell(key) .opacity(0.45) } else { NavigationLink(value: key) { dayCell(key) } .buttonStyle(.plain) } } else { Color.clear .frame(height: 74) } } } } .padding(14) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } private func dayCell(_ key: Date) -> some View { let isToday = key == math.dayKey(for: .now) let entry = entriesByDay[key] // 기분 사진이 있으면 셀 배경(백레이어)으로 깔아 달력에서 바로 보이게 한다 let photo = entry.flatMap { DiaryMoodThumbs.thumbnail(for: $0) } return VStack(spacing: 3) { Text("\(calendar.component(.day, from: key))") .font(.callout.weight(isToday ? .bold : .regular)) .monospacedDigit() .foregroundStyle(isToday || photo != nil ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) .shadow(color: photo != nil ? .black.opacity(0.55) : .clear, radius: 2) .frame(width: 32, height: 32) .background( Circle().fill(isToday ? AppTheme.green : Color.clear) ) // 작성 마커: 기분 이모지가 있으면 이모지, 아니면 연필 점 (사진 위에서도 유지) Group { if let entry { if entry.moodEmoji.isEmpty { Image(systemName: "applepencil.tip") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(photo != nil ? AnyShapeStyle(.white) : AnyShapeStyle(AppTheme.green)) .shadow(color: photo != nil ? .black.opacity(0.55) : .clear, radius: 2) } else { Text(entry.moodEmoji) .font(.system(size: 15)) } } else { Color.clear } } .frame(height: 20) } .frame(maxWidth: .infinity) .frame(height: 74) .background { if let photo { // Color.clear가 셀 크기를 잡고, 사진은 overlay로 채운 뒤 클리핑 — // scaledToFill 오버플로가 이웃 셀을 침범하지 않게 하는 표준 패턴 Color.clear .overlay(Image(uiImage: photo).resizable().scaledToFill()) .overlay(Color.black.opacity(0.16)) // 날짜·마커 가독성 스크림 .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } else { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(entry != nil ? AppTheme.green.opacity(0.07) : Color.clear) } } .contentShape(Rectangle()) } } // MARK: - 일기 상세 (페이지 넘김) struct DiaryDetailView: View { let dayKey: Date @Environment(\.modelContext) private var context @Environment(\.diaryReadOnly) private var readOnly @Query(sort: \DiaryTemplate.createdAt) private var templates: [DiaryTemplate] @State private var entry: DiaryEntry? /// 페이지 추가 선택 시트 (양식이 하나라도 있을 때만 사용 — 없으면 빈 페이지 즉시 추가) @State private var showingPageChooser = false @State private var showingSectionConfig: Bool = { #if DEBUG // 검증용: -diaryShowConfig YES → 첫 화면 구성 시트 바로 표시 if UserDefaults.standard.bool(forKey: "diaryShowConfig") { return true } #endif return false }() @State private var pageIndex: Int = { #if DEBUG // 검증용: -diaryPage N → 해당 페이지로 시작 (0=요약) if let raw = UserDefaults.standard.string(forKey: "diaryPage"), let index = Int(raw) { return index } #endif return 0 }() @State private var showingPageOrder = false var body: some View { Group { if let entry { pager(entry) } else if readOnly { ContentUnavailableView( "이 날짜의 일기가 없어요", systemImage: "book.closed", description: Text("프리미엄에서 새 일기를 쓸 수 있어요.") ) } else { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } } .background(AppTheme.background) .navigationTitle(Format.fullDate(dayKey)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { if !readOnly { detailMenu } } } .sheet(isPresented: $showingSectionConfig) { DiarySectionConfigSheet() } .sheet(isPresented: $showingPageChooser) { DiaryPageChooserSheet(templates: templates) { style in addPage(style: style) } } .onAppear(perform: loadEntry) #if DEBUG .onAppear { // 검증용: -diaryApplyTemplate YES → 첫 양식(여러 쪽이면 2쪽)을 배경으로 한 페이지 추가 if UserDefaults.standard.bool(forKey: "diaryApplyTemplate"), let template = templates.first, let entry, entry.pages.allSatisfy({ $0.templateID.isEmpty }) { addPage(style: .template(template, pageIndex: template.pageCount > 1 ? 1 : 0)) } // 검증용: -diaryShowPageChooser YES → 페이지 추가 선택 시트 바로 표시 if UserDefaults.standard.bool(forKey: "diaryShowPageChooser") { showingPageChooser = true } } #endif } private var detailMenu: some View { Menu { Button { showingSectionConfig = true } label: { Label("첫 화면 구성", systemImage: "slider.horizontal.3") } Button { requestAddPage() } label: { Label("페이지 추가", systemImage: "plus.square.on.square") } if let entry, entry.pages.count > 1 { Button { showingPageOrder = true } label: { Label("페이지 순서 변경", systemImage: "arrow.up.arrow.down") } } if let entry, pageIndex >= 1, pageIndex <= entry.pages.count { Button(role: .destructive) { deleteCurrentPage() } label: { Label("이 페이지 삭제", systemImage: "trash") } } } label: { Image(systemName: "ellipsis.circle") } .accessibilityLabel(Text("메뉴")) } private func pager(_ entry: DiaryEntry) -> some View { TabView(selection: $pageIndex) { DiarySummaryPage(entry: entry) .tag(0) ForEach(Array(entry.pages.enumerated()), id: \.element.uuid) { index, page in DiaryNotePageView(page: page, isActive: pageIndex == index + 1) .tag(index + 1) } if !readOnly { addPagePlaceholder .tag(entry.pages.count + 1) } } .tabViewStyle(.page(indexDisplayMode: .always)) .indexViewStyle(.page(backgroundDisplayMode: .always)) .sheet(isPresented: $showingPageOrder) { DiaryPageOrderSheet(entry: entry) } } /// 마지막 페이지: 누르면 새 노트 페이지가 그 자리에 생긴다 (빈 페이지가 계속 이어지는 노트) private var addPagePlaceholder: some View { Button { requestAddPage() } label: { VStack(spacing: 12) { Image(systemName: "plus") .font(.system(size: 34, weight: .semibold)) Text("새 페이지") .font(.headline) Text("애플 펜슬로 자유롭게 쓰고 그려 보세요") .font(.caption) .foregroundStyle(.secondary) } .foregroundStyle(AppTheme.green) .frame(maxWidth: 520, maxHeight: 680) .background( RoundedRectangle(cornerRadius: 20, style: .continuous) .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [8, 6])) .foregroundStyle(AppTheme.green.opacity(0.5)) ) .padding(40) } .buttonStyle(.plain) } private func loadEntry() { guard entry == nil else { return } let key = dayKey let descriptor = FetchDescriptor(predicate: #Predicate { $0.dayKey == key }) if let found = try? context.fetch(descriptor).first { entry = found } else { // 열람 전용(프리미엄 만료)에서는 새 엔트리를 만들지 않는다 → '일기 없음' 안내 표시 guard !readOnly else { return } let created = DiaryEntry(dayKey: key) context.insert(created) try? context.save() entry = created } } /// 양식이 없으면 기존처럼 빈 페이지를 즉시 추가하고(빠른 경로 유지), /// 양식이 있을 때만 [빈 캔버스/줄 노트/양식] 선택 시트를 띄운다. private func requestAddPage() { if templates.isEmpty { addPage(style: .blank) } else { showingPageChooser = true } } private func addPage(style: DiaryPageStyle = .blank) { guard let entry else { return } let page = DiaryPage(index: entry.pages.count) switch style { case .blank: break case .lined: page.lined = true case .template(let template, let templatePage): page.templateID = template.uuid.uuidString page.templatePageIndex = templatePage } page.entry = entry context.insert(page) entry.updatedAt = .now try? context.save() pageIndex = entry.pages.count // 새 페이지로 이동 } private func deleteCurrentPage() { guard let entry, pageIndex >= 1, pageIndex <= entry.pages.count else { return } let page = entry.pages[pageIndex - 1] context.delete(page) // 순서 재정렬 for (index, remaining) in entry.pages.filter({ $0 !== page }).enumerated() { remaining.index = index } entry.updatedAt = .now try? context.save() pageIndex = min(pageIndex, entry.pages.count) } } // MARK: - 페이지 순서 변경 시트 /// 노트 페이지들의 순서를 드래그로 바꾼다 (요약 페이지는 항상 첫 장이라 대상 아님) private struct DiaryPageOrderSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var context let entry: DiaryEntry var body: some View { NavigationStack { List { Section { ForEach(Array(entry.pages.enumerated()), id: \.element.uuid) { index, page in HStack(spacing: 12) { Image(systemName: page.lined ? "text.justify" : "square") .foregroundStyle(AppTheme.green) .frame(width: 24) VStack(alignment: .leading, spacing: 1) { Text("페이지 \(index + 1)") .font(.subheadline.weight(.medium)) Text(summary(page)) .font(.caption) .foregroundStyle(.secondary) } } } .onMove(perform: move) } footer: { Text("손잡이를 끌어 노트 페이지 순서를 바꿔요. 하루 정리(요약)는 항상 첫 장이에요.") } } .environment(\.editMode, .constant(.active)) .navigationTitle("페이지 순서") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("완료") { dismiss() } } } } .presentationDetents([.medium, .large]) } private func summary(_ page: DiaryPage) -> String { var parts: [String] = [] if !page.templateID.isEmpty { parts.append(String(localized: "양식")) } else { parts.append(page.lined ? String(localized: "줄 노트") : String(localized: "빈 캔버스")) } if !page.drawingData.isEmpty { parts.append(String(localized: "필기 있음")) } if !page.items.isEmpty { parts.append(String(localized: "요소 \(page.items.count)개")) } return parts.joined(separator: " · ") } private func move(from source: IndexSet, to destination: Int) { var pages = entry.pages pages.move(fromOffsets: source, toOffset: destination) for (index, page) in pages.enumerated() { page.index = index } entry.updatedAt = .now try? context.save() } } // MARK: - 첫 화면 섹션 구성 (표시 여부 + 순서) /// 일기 첫 페이지를 구성하는 섹션들. rawValue가 저장 키로 쓰인다. enum DiarySection: String, CaseIterable, Identifiable { case mood, hero, goals, todos, timetable, records, bars var id: String { rawValue } var label: LocalizedStringKey { switch self { case .mood: return "오늘 기분" case .hero: return "요약 지표" case .goals: return "이 날의 목표" case .todos: return "오늘 할 일" case .timetable: return "타임테이블" case .records: return "기록 상세" case .bars: return "통계 막대" } } var symbol: String { switch self { case .mood: return "face.smiling" case .hero: return "number.square" case .goals: return "flag.checkered" case .todos: return "checklist" case .timetable: return "calendar.day.timeline.left" case .records: return "list.bullet.rectangle" case .bars: return "chart.bar.fill" } } } /// 섹션 순서·숨김을 UserDefaults 문자열로 관리 (모든 날짜의 일기에 공통 적용). /// 순서 키에 없는 섹션(추후 추가분)은 목록 끝에 자동으로 붙는다. enum DiarySectionConfig { static let orderKey = "diary.sectionOrder" static let hiddenKey = "diary.hiddenSections" static func order(from raw: String) -> [DiarySection] { var result = raw.split(separator: ",").compactMap { DiarySection(rawValue: String($0)) } for section in DiarySection.allCases where !result.contains(section) { result.append(section) } return result } static func hidden(from raw: String) -> Set { Set(raw.split(separator: ",").compactMap { DiarySection(rawValue: String($0)) }) } static func rawValue(_ sections: some Sequence) -> String { sections.map(\.rawValue).joined(separator: ",") } /// 예전 '오늘 기분 표시' 토글(diary.showMood)을 새 숨김 목록으로 1회 이관 static func migrateIfNeeded() { let defaults = UserDefaults.standard guard !defaults.bool(forKey: "diary.sectionsMigrated") else { return } defaults.set(true, forKey: "diary.sectionsMigrated") if let old = AppGroup.defaults.object(forKey: "diary.showMood") as? Bool, old == false { var hiddenSet = hidden(from: defaults.string(forKey: hiddenKey) ?? "") hiddenSet.insert(.mood) defaults.set(rawValue(hiddenSet), forKey: hiddenKey) } } } /// 첫 화면 구성 시트: 드래그로 순서 변경, 스위치로 표시/숨김 struct DiarySectionConfigSheet: View { @Environment(\.dismiss) private var dismiss @AppStorage(DiarySectionConfig.orderKey) private var orderRaw = "" @AppStorage(DiarySectionConfig.hiddenKey) private var hiddenRaw = "" private var sections: [DiarySection] { DiarySectionConfig.order(from: orderRaw) } private var hiddenSections: Set { DiarySectionConfig.hidden(from: hiddenRaw) } var body: some View { NavigationStack { List { Section { ForEach(sections) { section in HStack(spacing: 12) { Image(systemName: section.symbol) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(AppTheme.green) .frame(width: 26) Toggle(isOn: visibilityBinding(section)) { Text(section.label) .font(.subheadline) } .tint(AppTheme.green) } } .onMove(perform: move) } footer: { Text("드래그해서 순서를 바꾸고, 스위치로 표시 여부를 정할 수 있어요. 모든 날짜의 일기 첫 화면에 함께 적용돼요.") } } .environment(\.editMode, .constant(.active)) .navigationTitle("첫 화면 구성") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("닫기") { dismiss() } } } } .presentationDetents([.medium, .large]) } private func visibilityBinding(_ section: DiarySection) -> Binding { Binding( get: { !hiddenSections.contains(section) }, set: { visible in var hiddenSet = hiddenSections if visible { hiddenSet.remove(section) } else { hiddenSet.insert(section) } hiddenRaw = DiarySectionConfig.rawValue( DiarySection.allCases.filter(hiddenSet.contains) // 안정적 순서로 저장 ) } ) } private func move(from source: IndexSet, to destination: Int) { var reordered = sections reordered.move(fromOffsets: source, toOffset: destination) orderRaw = DiarySectionConfig.rawValue(reordered) } } // MARK: - 첫 페이지 (하루 정리) /// "오늘 하루를 정리한 모습"이 목표인 첫 페이지. /// - 타임테이블은 기록 유무와 무관하게 항상 24시간 전체를 보여준다 (trimHours: false) /// - 그 날짜에 진행 중이던 목표들을 찾아 다짐별 진행 현황을 보여준다. /// 주간/월간 다짐은 "그 날짜까지의 누적"으로 계산해 과거 일기에서도 그날 기준 수치가 된다. /// - 섹션 표시 여부·순서는 DiarySectionConfig(첫 화면 구성 시트)를 따른다. private struct DiarySummaryPage: View { @Bindable var entry: DiaryEntry @Environment(\.modelContext) private var context @Environment(\.diaryReadOnly) private var readOnly // 이 날짜(논리적 하루)에 겹치는 기록만 조회 — 전량 로드 방지 (HistoryRecordsView와 같은 규칙) @Query private var sessions: [TimeSession] @Query private var countEntries: [CountEntry] init(entry: DiaryEntry) { self.entry = entry let math = DayMath() let range = math.dayRange(forKey: entry.dayKey) let lower = range.lowerBound let upper = range.upperBound let farFuture = Date.distantFuture _sessions = Query(filter: #Predicate { $0.startAt < upper && ($0.endAt ?? farFuture) > lower }) _countEntries = Query(filter: #Predicate { $0.timestamp >= lower && $0.timestamp < upper }) } @Query(sort: \Action.createdAt) private var allActionsQuery: [Action] @Query(sort: \Goal.sortOrder) private var allGoals: [Goal] @Query(sort: \Tag.sortOrder) private var allTags: [Tag] @AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = "" @AppStorage(DiarySectionConfig.orderKey) private var sectionOrderRaw = "" @AppStorage(DiarySectionConfig.hiddenKey) private var hiddenSectionsRaw = "" /// 이 날짜에 선택된 캘린더 일정 → 타임테이블 블록 (선택 변경 시 .task로 재조회) @State private var calendarBlocks: [ExportTimetableData.Block] = [] @State private var showingCalendarPicker: Bool = { #if DEBUG // 검증용: -diaryShowCalendarPicker YES → 일정 선택 팝업 바로 표시 if UserDefaults.standard.bool(forKey: "diaryShowCalendarPicker") { return true } #endif return false }() /// 타임테이블 행동 필터 팝업 — 선택 상태는 시트가 열려 있는 동안 이 집합으로 편집하고, /// 변경 즉시 entry.hiddenActionIDs(uuid 문자열, 날짜별)로 저장한다. @State private var showingActionFilter = false @State private var filterExcluded: Set = [] /// '이 날의 목표' 필터 팝업 — 선택은 entry.hiddenGoalIDs(uuid 문자열, 날짜별)로 즉시 저장 @State private var showingGoalFilter = false private var math: DayMath { DayMath() } /// 표시할 섹션 (구성 설정의 순서, 숨김 제외) private var visibleSections: [DiarySection] { let hidden = DiarySectionConfig.hidden(from: hiddenSectionsRaw) return DiarySectionConfig.order(from: sectionOrderRaw).filter { !hidden.contains($0) } } private var orderedActions: [Action] { LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw) } /// 진행률 계산 기준 시각: 오늘 일기면 지금, 과거/미래 일기면 그 날짜의 끝 private var referenceNow: Date { let range = math.dayRange(forKey: entry.dayKey) return range.contains(.now) ? .now : range.upperBound.addingTimeInterval(-1) } /// 이 날짜에 진행 중이던 목표 (시작일 도래 & 종료일 이전) private var activeGoals: [Goal] { let day = math.dayRange(forKey: entry.dayKey).lowerBound let cal = math.calendar return allGoals.filter { goal in guard cal.startOfDay(for: goal.startDate) <= day else { return false } if let end = goal.endDate { return cal.startOfDay(for: end) >= day } return true } } /// 이 날짜에 숨기기로 한 목표를 뺀 표시 대상 (uuid 문자열 저장 — 기기 간 동기화에 안전) private var visibleGoals: [Goal] { guard !entry.hiddenGoalIDs.isEmpty else { return activeGoals } let hidden = Set(entry.hiddenGoalIDs) return activeGoals.filter { !hidden.contains($0.uuid.uuidString) } } /// 그날의 기록 스냅숏 (이미지 내보내기와 동일한 데이터 — 24시간 타임테이블 + 기록 목록) private var daySnapshot: ExportSnapshot { ExportBuilder.history( dayKey: entry.dayKey, weeklyTimetable: false, sessions: sessions, entries: countEntries, orderedActions: orderedActions, excludedActionIDs: [], math: math, trimHours: false ) } /// 이 날짜에 숨기기로 한 행동들 (저장된 uuid 문자열 → 현재 기기의 모델 ID) private var timetableExcludedIDs: Set { guard !entry.hiddenActionIDs.isEmpty else { return [] } let hidden = Set(entry.hiddenActionIDs) return Set(orderedActions.filter { hidden.contains($0.uuid.uuidString) }.map(\.persistentModelID)) } /// 타임테이블 전용 스냅숏 — 이 날짜의 필터(숨긴 행동)만 반영. 기록·통계 섹션은 전체(daySnapshot) 유지. private var timetableSnapshot: ExportSnapshot { guard !timetableExcludedIDs.isEmpty else { return daySnapshot } return ExportBuilder.history( dayKey: entry.dayKey, weeklyTimetable: false, sessions: sessions, entries: countEntries, orderedActions: orderedActions, excludedActionIDs: timetableExcludedIDs, math: math, trimHours: false ) } /// 이 날짜에 기록이 있는 행동들 — 필터 팝업에는 그날 실제로 기록된 행동만 나열해 고르기 쉽게 한다 private var dayRecordedActions: [Action] { let range = math.dayRange(forKey: entry.dayKey) var ids = Set() for session in sessions { let end = session.endAt ?? .now if session.startAt < range.upperBound, end > range.lowerBound, let action = session.action { ids.insert(action.persistentModelID) } } for countEntry in countEntries where range.contains(countEntry.timestamp) { if let action = countEntry.action { ids.insert(action.persistentModelID) } } return orderedActions.filter { ids.contains($0.persistentModelID) } } /// 꼬리표별 시간/횟수 막대 (하루 통계에서 발췌) private var statBars: [ExportSectionData] { ExportBuilder.stats( span: .day, anchorDayKey: entry.dayKey, sessions: sessions, entries: countEntries, orderedActions: orderedActions, excludedActionIDs: [], math: math ).sections.filter { if case .bars = $0 { return true } return false } } var body: some View { GeometryReader { geo in let sections = visibleSections // 넓은 화면 + 타임테이블 표시 중일 때만 2컬럼 (타임테이블이 왼쪽 컬럼 전담, // 나머지 섹션은 설정한 순서대로 오른쪽 컬럼에) let wide = geo.size.width >= 720 && sections.contains(.timetable) let timetableWidth: CGFloat = 300 let rightWidth = geo.size.width - 40 - timetableWidth - 16 let fullWidth = geo.size.width - 40 ScrollView { VStack(alignment: .leading, spacing: 14) { header if sections.isEmpty { Text("표시할 항목이 없어요. 오른쪽 위 메뉴의 '첫 화면 구성'에서 켜 주세요.") .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding(.vertical, 40) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } else if wide { HStack(alignment: .top, spacing: 16) { VStack(spacing: 14) { timetableSections(innerWidth: timetableWidth - 28) } .frame(width: timetableWidth) VStack(spacing: 14) { ForEach(sections.filter { $0 != .timetable }) { section in sectionView(section, innerWidth: rightWidth - 28) } } } } else { ForEach(sections) { section in sectionView(section, innerWidth: fullWidth - 28) } } } .padding(20) } } .onAppear { DiarySectionConfig.migrateIfNeeded() } .sheet(isPresented: $showingCalendarPicker) { DiaryCalendarPickerSheet(entry: entry) } .sheet(isPresented: $showingActionFilter) { RecordFilterSheet( title: "타임테이블 필터", tags: allTags, actions: dayRecordedActions, excludedActionIDs: $filterExcluded ) } .sheet(isPresented: $showingGoalFilter) { DiaryGoalFilterSheet(entry: entry, goals: activeGoals) } .onChange(of: filterExcluded) { _, newValue in // 시트에서 고르는 즉시 반영 + 날짜별로 저장 (uuid 문자열이라 기기 간 동기화에도 안전) let hidden = orderedActions .filter { newValue.contains($0.persistentModelID) } .map(\.uuid.uuidString) guard hidden != entry.hiddenActionIDs else { return } entry.hiddenActionIDs = hidden entry.updatedAt = .now try? context.save() } .task(id: entry.calendarEventIDs) { #if DEBUG await DiaryCalendarEvents.seedForVerification(entry: entry, math: math) #endif calendarBlocks = DiaryCalendarEvents.timetableBlocks(for: entry, math: math) } #if DEBUG .task { // 검증용: -diaryHideFirstAction YES → 그날 첫 행동을 숨김 상태로 만들어 필터 동작 확인 if UserDefaults.standard.bool(forKey: "diaryHideFirstAction"), entry.hiddenActionIDs.isEmpty, let first = dayRecordedActions.first { entry.hiddenActionIDs = [first.uuid.uuidString] } // 검증용: -diaryShowActionFilter YES → 필터 팝업 바로 표시 if UserDefaults.standard.bool(forKey: "diaryShowActionFilter") { filterExcluded = timetableExcludedIDs showingActionFilter = true } // 검증용: -diaryHideFirstGoal YES → 그날 첫 목표를 숨김 상태로 만들어 필터 동작 확인 if UserDefaults.standard.bool(forKey: "diaryHideFirstGoal"), entry.hiddenGoalIDs.isEmpty, let first = activeGoals.first { entry.hiddenGoalIDs = [first.uuid.uuidString] } // 검증용: -diaryShowGoalFilter YES → 목표 필터 팝업 바로 표시 if UserDefaults.standard.bool(forKey: "diaryShowGoalFilter") { showingGoalFilter = true } } #endif } /// 섹션 종류 → 실제 카드 뷰 @ViewBuilder private func sectionView(_ section: DiarySection, innerWidth: CGFloat) -> some View { switch section { case .mood: DiaryMoodCard(entry: entry) case .hero: ExportHeroRow(stats: daySnapshot.hero) case .goals: DiaryGoalsCard( goals: visibleGoals, totalCount: activeGoals.count, hiddenCount: activeGoals.count - visibleGoals.count, onFilter: (readOnly || activeGoals.isEmpty) ? nil : { showingGoalFilter = true }, dayKey: entry.dayKey, referenceNow: referenceNow, math: math ) case .todos: DiaryTodoCard(entry: entry) case .timetable: timetableSections(innerWidth: innerWidth) case .records: recordSections(innerWidth: innerWidth) case .bars: barSections(innerWidth: innerWidth) } } private var header: some View { HStack(alignment: .firstTextBaseline, spacing: 10) { Text(Format.fullDate(entry.dayKey)) .font(.system(size: 28, weight: .bold)) if entry.dayKey == math.dayKey(for: .now) { Text("오늘") .font(.caption.weight(.bold)) .foregroundStyle(.white) .padding(.horizontal, 10) .padding(.vertical, 4) .background(AppTheme.green, in: Capsule()) } Spacer() } } /// 타임테이블 카드 + 우상단 버튼들 /// - 필터 버튼: 그날 기록된 행동 중 원하는 것만 골라 표시 (날짜별 저장, 내보내기에도 반영) /// - 캘린더 버튼: 그날 넣을 캘린더 일정을 고르는 팝업 @ViewBuilder private func timetableSections(innerWidth: CGFloat) -> some View { let injected = timetableSnapshot.injectingCalendarEvents(calendarBlocks) let hiddenCount = timetableExcludedIDs.count ForEach(Array(injected.sections.enumerated()), id: \.offset) { _, section in if case .timetable = section { ZStack(alignment: .topTrailing) { ExportSectionView(section: section, innerWidth: innerWidth) // 열람 전용에서는 날짜별 선택(필터·캘린더)을 편집할 수 없다 — 저장된 표시만 유지 HStack(spacing: 6) { if !readOnly, !dayRecordedActions.isEmpty || hiddenCount > 0 { Button { filterExcluded = timetableExcludedIDs showingActionFilter = true } label: { HStack(spacing: 4) { Image(systemName: "line.3.horizontal.decrease") .font(.system(size: 12, weight: .semibold)) if hiddenCount > 0 { Text(verbatim: "\(hiddenCount)") .font(.system(size: 11, weight: .bold).monospacedDigit()) } } .foregroundStyle(hiddenCount > 0 ? AppTheme.yellow : AppTheme.green) .padding(.horizontal, 8) .padding(.vertical, 5) .background( (hiddenCount > 0 ? AppTheme.yellow : AppTheme.green).opacity(0.12), in: Capsule() ) } .buttonStyle(.plain) .accessibilityLabel(Text("타임테이블 필터")) } if !readOnly { Button { showingCalendarPicker = true } label: { HStack(spacing: 4) { Image(systemName: "calendar.badge.plus") .font(.system(size: 12, weight: .semibold)) if !calendarBlocks.isEmpty { Text(verbatim: "\(calendarBlocks.count)") .font(.system(size: 11, weight: .bold).monospacedDigit()) } } .foregroundStyle(AppTheme.green) .padding(.horizontal, 8) .padding(.vertical, 5) .background(AppTheme.green.opacity(0.12), in: Capsule()) } .buttonStyle(.plain) .accessibilityLabel(Text("캘린더 일정")) } } .padding(10) } } } } @ViewBuilder private func recordSections(innerWidth: CGFloat) -> some View { let recordSections = daySnapshot.sections.filter { if case .records = $0 { return true } return false } if recordSections.isEmpty { Text("이 날의 기록이 없어요") .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding(.vertical, 30) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } else { ForEach(Array(recordSections.enumerated()), id: \.offset) { _, section in ExportSectionView(section: section, innerWidth: innerWidth) } } } @ViewBuilder private func barSections(innerWidth: CGFloat) -> some View { ForEach(Array(statBars.enumerated()), id: \.offset) { _, section in ExportSectionView(section: section, innerWidth: innerWidth) } } } // MARK: - 이 날의 목표·다짐 현황 카드 /// 그 날짜에 진행 중이던 목표들의 다짐별 진행 현황. /// - 하루 다짐: 그 날짜가 수행일이면 그날의 실적/목표, 아니면 "수행일 아님"으로 표시 /// - 주간/월간/기간 다짐: 주기 시작부터 "그 날짜 끝"까지의 누적을 목표량과 비교 /// - '이하 유지' 다짐은 한도 대비 사용량 게이지(노랑, 초과 시 빨강)로 달성형과 구분 private struct DiaryGoalsCard: View { /// 필터를 통과해 표시할 목표들 let goals: [Goal] /// 이 날짜에 진행 중인 목표 전체 수 (숨긴 것 포함) let totalCount: Int /// 이 날짜에 숨긴 목표 수 (필터 버튼 배지) let hiddenCount: Int /// 필터 버튼 동작. nil = 버튼 숨김 (열람 전용이거나 진행 중 목표가 없을 때) let onFilter: (() -> Void)? let dayKey: Date let referenceNow: Date let math: DayMath var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { Text("이 날의 목표") .font(.subheadline.weight(.semibold)) Spacer() Text(String(localized: "\(totalCount)개 진행 중")) .font(.caption2) .foregroundStyle(.secondary) if let onFilter { // 타임테이블 필터 버튼과 같은 모양 — 숨긴 목표가 있으면 노란 배지 Button(action: onFilter) { HStack(spacing: 4) { Image(systemName: "line.3.horizontal.decrease") .font(.system(size: 12, weight: .semibold)) if hiddenCount > 0 { Text(verbatim: "\(hiddenCount)") .font(.system(size: 11, weight: .bold).monospacedDigit()) } } .foregroundStyle(hiddenCount > 0 ? AppTheme.yellow : AppTheme.green) .padding(.horizontal, 8) .padding(.vertical, 5) .background( (hiddenCount > 0 ? AppTheme.yellow : AppTheme.green).opacity(0.12), in: Capsule() ) } .buttonStyle(.plain) .accessibilityLabel(Text("목표 필터")) } } if goals.isEmpty { Group { if totalCount == 0 { Text("이 날짜에 진행 중인 목표가 없어요") } else { Text("모든 목표를 숨겼어요. 필터에서 다시 선택할 수 있어요.") } } .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding(.vertical, 14) } else { ForEach(Array(goals.enumerated()), id: \.element.uuid) { index, goal in if index > 0 { Divider() } goalBlock(goal) } } } .padding(14) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } // MARK: 목표 한 덩어리 (헤더 + 다짐 줄들) @ViewBuilder private func goalBlock(_ goal: Goal) -> some View { let dayRatio = goal.combinedSpanRatio(.day, math: math, now: referenceNow) VStack(alignment: .leading, spacing: 10) { HStack(spacing: 10) { Image(systemName: goal.symbolName) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.white) .frame(width: 28, height: 28) .background(goal.color, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) Text(goal.title) .font(.footnote.weight(.semibold)) .lineLimit(1) Spacer(minLength: 6) if goal.sortedQuests.isEmpty { // 다짐 없는 목표의 0%는 "실패 중"으로 오독됨 — 상태 문구로 표기 Text("다짐 없음") .font(.caption.weight(.medium)) .foregroundStyle(.tertiary) } else { Text("\(Int((dayRatio * 100).rounded()))%") .font(.footnote.weight(.bold).monospacedDigit()) .foregroundStyle(goal.color) } } // 목표 단위 하루 진행률 (다짐 진행률 평균) — 다짐 없으면 게이지도 생략 if !goal.sortedQuests.isEmpty { gaugeBar(ratio: dayRatio, color: goal.color) } if goal.sortedQuests.isEmpty { Text("다짐이 아직 없는 목표예요") .font(.caption2) .foregroundStyle(.tertiary) } else { ForEach(goal.sortedQuests, id: \.uuid) { quest in questRow(quest) } } } } private func gaugeBar(ratio: Double, color: Color, height: CGFloat = 6) -> some View { GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(color.opacity(0.15)) Capsule() .fill(color) .frame(width: max(geo.size.width * min(max(ratio, 0), 1), ratio > 0 ? height : 0)) } } .frame(height: height) } // MARK: 다짐 한 줄 private struct QuestStat { var contextLabel: String var value: Double? var achieved: Bool } @ViewBuilder private func questRow(_ quest: Quest) -> some View { let stat = questStat(quest) let target = quest.targetValue let limit = quest.direction == .atMost HStack(alignment: .firstTextBaseline, spacing: 8) { Image(systemName: quest.targetSymbol) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.white) .frame(width: 20, height: 20) .background(quest.targetColor, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(quest.targetName) .font(.caption.weight(.medium)) .lineLimit(1) Text(stat.contextLabel) .font(.caption2) .foregroundStyle(.tertiary) Spacer(minLength: 4) if let value = stat.value { Group { if limit { Text("\(valueLabel(value, quest: quest)) / 한도 \(quest.targetValueLabel)") } else { Text("\(valueLabel(value, quest: quest)) / \(quest.targetValueLabel)") } } .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) if stat.achieved { Image(systemName: "checkmark.circle.fill") .font(.caption) .foregroundStyle(AppTheme.green) } else if limit { Image(systemName: "exclamationmark.circle.fill") .font(.caption) .foregroundStyle(.red) } } } if let value = stat.value, target > 0 { let ratio = value / target gaugeBar( ratio: ratio, color: limit ? (ratio > 1 ? .red : AppTheme.yellow) : quest.targetColor, height: 4 ) } } } .opacity(stat.value == nil ? 0.55 : 1) } /// 다짐의 "그 날짜 기준" 실적. value == nil이면 이 날짜엔 해당 없음(수행일 아님/기간 밖). private func questStat(_ quest: Quest) -> QuestStat { let progress = QuestProgress(quest: quest, math: math) let dayRange = math.dayRange(forKey: dayKey) let measured: Range? let context: String switch quest.period { case .daily: if progress.isActiveDay(dayKey) { // 마감 시각 다짐은 그날의 집계 창(하루 시작~마감)만 잰다 — 목표 탭과 동일 규칙 measured = progress.dayMeasurementRange(forKey: dayKey) var label = String(localized: "하루 목표") if let deadline = quest.deadlineLabel { label += " · " + deadline } context = label } else { measured = nil context = String(localized: "이 날은 수행일이 아니에요") } case .weekly: let week = math.weekRange(containing: dayRange.lowerBound) measured = week.lowerBound..= quest.targetValue case .atMost: achieved = value <= quest.targetValue } return QuestStat(contextLabel: context, value: value, achieved: achieved) } private func valueLabel(_ value: Double, quest: Quest) -> String { switch quest.measure { case .time: return Format.durationShort(value) case .count: return String(localized: "\(Int(value))회") } } } // MARK: - 이 날의 목표 필터 시트 /// '이 날의 목표' 카드에 표시할 목표를 고르는 시트. /// 캘린더 일정 선택과 같은 패턴 — 전체 선택/해제 + 개별 체크, 선택은 이 날짜에만 적용된다. private struct DiaryGoalFilterSheet: View { @Bindable var entry: DiaryEntry /// 이 날짜에 진행 중인 목표 전체 (숨긴 것도 나열해야 다시 켤 수 있다) let goals: [Goal] @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var context private var hidden: Set { Set(entry.hiddenGoalIDs) } private var allSelected: Bool { entry.hiddenGoalIDs.isEmpty } var body: some View { NavigationStack { List { Section { Button(allSelected ? String(localized: "전체 해제") : String(localized: "전체 선택")) { save(allSelected ? goals.map(\.uuid.uuidString) : []) } .font(.callout.weight(.semibold)) ForEach(goals, id: \.uuid) { goal in goalRow(goal) } } footer: { Text("선택한 목표만 이 날짜의 '이 날의 목표' 카드에 표시돼요. 날짜마다 따로 선택할 수 있어요.") } } .scrollContentBackground(.hidden) .background(AppTheme.background) .navigationTitle("목표 필터") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("닫기") { dismiss() } } } } .presentationDetents([.medium, .large]) } private func goalRow(_ goal: Goal) -> some View { let id = goal.uuid.uuidString let isOn = !hidden.contains(id) return Button { toggle(id) } label: { HStack(spacing: 10) { Image(systemName: goal.symbolName) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.white) .frame(width: 28, height: 28) .background(goal.color, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) Text(goal.title) .font(.subheadline) .foregroundStyle(.primary) .lineLimit(1) Spacer() Image(systemName: isOn ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(isOn ? AppTheme.green : .secondary) } .contentShape(Rectangle()) } .buttonStyle(.plain) } private func toggle(_ id: String) { var ids = entry.hiddenGoalIDs if let index = ids.firstIndex(of: id) { ids.remove(at: index) } else { ids.append(id) } save(ids) } private func save(_ ids: [String]) { entry.hiddenGoalIDs = ids entry.updatedAt = .now try? context.save() } } // MARK: - 오늘 기분 카드 private struct DiaryMoodCard: View { @Bindable var entry: DiaryEntry @Environment(\.modelContext) private var context @Environment(\.diaryReadOnly) private var readOnly @State private var showingEmojiPicker = false @State private var photoSelection: PhotosPickerItem? /// 기분 타일 크기 — 카드의 주인공이 되도록 큼직한 정사각형 private let tileSize: CGFloat = 200 static let emojiChoices = [ "😀", "🙂", "😌", "🥰", "🥳", "🤗", "😴", "😐", "😔", "😢", "😡", "😤", "🤒", "🤯", "😱", "🙃", ] var body: some View { HStack(alignment: .top, spacing: 16) { moodTile VStack(alignment: .leading, spacing: 6) { Text("오늘 기분") .font(.headline) Text("이모지나 사진으로\n오늘의 기분을 남겨 보세요") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 8) if !readOnly { VStack(alignment: .leading, spacing: 8) { Button { showingEmojiPicker = true } label: { Label("이모지 선택", systemImage: "face.smiling") } .popover(isPresented: $showingEmojiPicker) { emojiPicker } PhotosPicker(selection: $photoSelection, matching: .images) { Label("사진 선택", systemImage: "photo.badge.plus") } if !entry.moodEmoji.isEmpty || entry.moodImageData != nil { Button { entry.moodEmoji = "" entry.moodImageData = nil touch() } label: { Label("지우기", systemImage: "xmark.circle") } .foregroundStyle(.secondary) } } .font(.footnote) .buttonStyle(.bordered) .buttonBorderShape(.capsule) .tint(AppTheme.green) } } .frame(maxWidth: .infinity, minHeight: tileSize, alignment: .leading) } .padding(14) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .onChange(of: photoSelection) { guard let photoSelection else { return } Task { if let data = try? await photoSelection.loadTransferable(type: Data.self), let image = UIImage(data: data) { // 저장 용량을 위해 리사이즈 + JPEG 압축 entry.moodImageData = image.diaryCompressed() touch() } self.photoSelection = nil } } } /// 큼직한 정사각형 기분 타일: 사진이 있으면 사진이 꽉 차고(이모지는 배지로), /// 이모지만 있으면 크게 가운데에, 둘 다 없으면 탭 안내 플레이스홀더. private var moodTile: some View { Button { showingEmojiPicker = true } label: { ZStack { RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(AppTheme.background) if let data = entry.moodImageData, let image = UIImage(data: data) { Image(uiImage: image) .resizable() .scaledToFill() .frame(width: tileSize, height: tileSize) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) if !entry.moodEmoji.isEmpty { Text(entry.moodEmoji) .font(.system(size: 40)) .padding(7) .background(.thinMaterial, in: Circle()) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) .padding(10) } } else if !entry.moodEmoji.isEmpty { Text(entry.moodEmoji) .font(.system(size: 110)) } else { VStack(spacing: 10) { Image(systemName: "face.smiling") .font(.system(size: 44)) Text("탭해서 남기기") .font(.caption) } .foregroundStyle(.tertiary) } } .frame(width: tileSize, height: tileSize) .contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) } .buttonStyle(.plain) .disabled(readOnly) .accessibilityLabel(Text("오늘 기분")) } private var emojiPicker: some View { LazyVGrid(columns: Array(repeating: GridItem(.fixed(46)), count: 4), spacing: 8) { ForEach(Self.emojiChoices, id: \.self) { emoji in Button { entry.moodEmoji = emoji touch() showingEmojiPicker = false } label: { Text(emoji) .font(.system(size: 30)) .frame(width: 46, height: 46) } .buttonStyle(.plain) } } .padding(12) .presentationCompactAdaptation(.popover) } private func touch() { entry.updatedAt = .now try? context.save() } } extension UIImage { /// 기분 사진 저장용 축소 (최대 변 900pt, JPEG 0.8) func diaryCompressed() -> Data? { let maxSide: CGFloat = 900 let longest = max(size.width, size.height) let scale = longest > maxSide ? maxSide / longest : 1 let newSize = CGSize(width: size.width * scale, height: size.height * scale) let renderer = UIGraphicsImageRenderer(size: newSize) let resized = renderer.image { _ in draw(in: CGRect(origin: .zero, size: newSize)) } return resized.jpegData(compressionQuality: 0.8) } } // MARK: - 오늘 할 일 카드 (펜슬 스크리블 입력 + 탭 체크) private struct DiaryTodoCard: View { @Bindable var entry: DiaryEntry @Environment(\.modelContext) private var context @Environment(\.diaryReadOnly) private var readOnly var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("오늘 할 일") .font(.subheadline.weight(.semibold)) Spacer() if !readOnly { Button { addTodo() } label: { Image(systemName: "plus.circle.fill") .font(.title3) .foregroundStyle(AppTheme.green) } .buttonStyle(.plain) } } if entry.todos.isEmpty { Text("펜슬이나 키보드로 할 일을 적고,\n체크 표시로 완료해 보세요") .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding(.vertical, 14) } else { ForEach(entry.todos, id: \.uuid) { todo in DiaryTodoRow(todo: todo, onDelete: { delete(todo) }) } } } .padding(14) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } private func addTodo() { let todo = DiaryTodo(text: "", sortOrder: (entry.todos.last?.sortOrder ?? -1) + 1) todo.entry = entry context.insert(todo) entry.updatedAt = .now try? context.save() } private func delete(_ todo: DiaryTodo) { context.delete(todo) entry.updatedAt = .now try? context.save() } } private struct DiaryTodoRow: View { @Bindable var todo: DiaryTodo let onDelete: () -> Void @Environment(\.modelContext) private var context @Environment(\.diaryReadOnly) private var readOnly var body: some View { HStack(spacing: 10) { Button { todo.isDone.toggle() try? context.save() } label: { Image(systemName: todo.isDone ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(todo.isDone ? AppTheme.green : .secondary) } .buttonStyle(.plain) .disabled(readOnly) .accessibilityLabel(todo.isDone ? String(localized: "완료 표시 해제") : String(localized: "완료로 표시")) TextField("할 일", text: $todo.text) .font(.subheadline) .strikethrough(todo.isDone, color: .secondary) .foregroundStyle(todo.isDone ? .secondary : .primary) .onSubmit { try? context.save() } .disabled(readOnly) if !readOnly { Button(action: onDelete) { Image(systemName: "xmark") .font(.caption2.weight(.semibold)) .foregroundStyle(.tertiary) .padding(4) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(Text("삭제")) } } } }