// // DiaryView.swift // Haru_Danim // // 일기 탭 (iPad 전용, 프리미엄 — CLAUDE.md §7.2 확장 기능). // 구조: // - DiaryRootView: 월 달력. 일기를 쓴 날짜에 마커(기분 이모지 또는 초록 점)가 붙고, // 날짜를 누르면 그 날짜의 일기로 이동. 툴바에서 기간/복수 날짜 내보내기. // - DiaryDetailView: 좌우로 넘기는 페이지 구성. 첫 페이지 = 요약(오늘 날짜·기분·할 일· // 타임테이블·통계), 이후 페이지 = 자유 필기 노트(DiaryNotePage.swift), 마지막 = 페이지 추가. // - DiarySummaryPage: 그날의 기록/통계는 이미지 내보내기와 같은 스냅숏(ExportBuilder)을 // 재사용해 화면과 내보내기 결과가 항상 일치한다. // import SwiftUI import SwiftData import PhotosUI // 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 private var math: DayMath { DayMath() } var body: some View { Group { if !premium.isPremium { DiaryLockedView() } else { #if DEBUG // 검증용: -diaryOpenToday YES → 달력 없이 오늘 일기를 바로 렌더 if UserDefaults.standard.bool(forKey: "diaryOpenToday") { DiaryDetailView(dayKey: math.dayKey(for: .now)) } else { calendar } #else calendar #endif } } .background(AppTheme.background) .navigationTitle("일기") .navigationBarTitleDisplayMode(.inline) .onAppear { #if DEBUG // 검증용: -diarySeed YES → 오늘 일기(기분·할 일·노트 페이지) 데모 생성 if UserDefaults.standard.bool(forKey: "diarySeed") { seedToday() } #endif } } private var calendar: some View { ScrollView { VStack(spacing: 14) { monthHeader DiaryCalendarGrid( monthAnchor: monthAnchor, entriesByDay: entriesByDay, math: math ) monthSummary } .padding(20) .frame(maxWidth: 760) .frame(maxWidth: .infinity) } .navigationDestination(for: Date.self) { key in DiaryDetailView(dayKey: key) } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { showingExport = true } label: { Image(systemName: "square.and.arrow.up") } } } .sheet(isPresented: $showingExport) { DiaryExportSheet() } .onAppear { #if DEBUG if UserDefaults.standard.bool(forKey: "diaryShowExport") { showingExport = true } #endif } } /// 작성된 일기(내용 있는 것만)를 하루 키로 색인 private var entriesByDay: [Date: DiaryEntry] { var result: [Date: DiaryEntry] = [:] for entry in diaryEntries where entry.hasContent { result[entry.dayKey] = entry } return result } // MARK: 월 이동 헤더 private var monthHeader: some View { HStack { Button { moveMonth(-1) } label: { Image(systemName: "chevron.left") .frame(width: 44, height: 36) .contentShape(Rectangle()) } 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()) } 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 { let range = math.monthRange(containing: monthAnchor) 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 = ["물 2리터 마시기", "독서 30분", "저녁 달리기"] 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) try? context.save() } #endif } // MARK: - 프리미엄 잠금 안내 struct DiaryLockedView: View { 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나만의 다이어리를 만들 수 있어요.\n설정 → 프리미엄에서 잠금 해제해 주세요.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } // MARK: - 달력 그리드 private struct DiaryCalendarGrid: View { let monthAnchor: Date let entriesByDay: [Date: DiaryEntry] let math: DayMath private var calendar: Calendar { math.calendar } /// 주 시작 요일 설정을 따르는 요일 헤더 순서 private var weekdayOrder: [Int] { (0..<7).map { (calendar.firstWeekday - 1 + $0) % 7 + 1 } } /// 이 달 그리드 셀 (앞쪽 빈칸은 nil) private var cells: [Date?] { let range = math.monthRange(containing: monthAnchor) 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 { 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] return VStack(spacing: 3) { Text("\(calendar.component(.day, from: key))") .font(.callout.weight(isToday ? .bold : .regular)) .monospacedDigit() .foregroundStyle(isToday ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) .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(AppTheme.green) } else { Text(entry.moodEmoji) .font(.system(size: 15)) } } else { Color.clear } } .frame(height: 20) } .frame(maxWidth: .infinity) .frame(height: 74) .background( 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 @AppStorage("diary.showMood", store: AppGroup.defaults) private var showMood = true @State private var entry: DiaryEntry? @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 }() var body: some View { Group { if let entry { pager(entry) } else { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } } .background(AppTheme.background) .navigationTitle(Format.fullDate(dayKey)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Menu { Toggle("‘오늘 기분’ 영역 표시", isOn: $showMood) Button { addPage() } label: { Label("페이지 추가", systemImage: "plus.square.on.square") } if let entry, pageIndex >= 1, pageIndex <= entry.pages.count { Button(role: .destructive) { deleteCurrentPage() } label: { Label("이 페이지 삭제", systemImage: "trash") } } } label: { Image(systemName: "ellipsis.circle") } } } .onAppear(perform: loadEntry) } private func pager(_ entry: DiaryEntry) -> some View { TabView(selection: $pageIndex) { DiarySummaryPage(entry: entry, showMood: showMood) .tag(0) ForEach(Array(entry.pages.enumerated()), id: \.element.uuid) { index, page in DiaryNotePageView(page: page, isActive: pageIndex == index + 1) .tag(index + 1) } addPagePlaceholder .tag(entry.pages.count + 1) } .tabViewStyle(.page(indexDisplayMode: .always)) .indexViewStyle(.page(backgroundDisplayMode: .always)) } /// 마지막 페이지: 누르면 새 노트 페이지가 그 자리에 생긴다 (빈 페이지가 계속 이어지는 노트) private var addPagePlaceholder: some View { Button { addPage() } 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 { let created = DiaryEntry(dayKey: key) context.insert(created) try? context.save() entry = created } } private func addPage() { guard let entry else { return } let page = DiaryPage(index: entry.pages.count) 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: - 첫 페이지 (하루 정리) /// "오늘 하루를 정리한 모습"이 목표인 첫 페이지. /// - 타임테이블은 기록 유무와 무관하게 항상 24시간 전체를 보여준다 (trimHours: false) /// - 그 날짜에 진행 중이던 목표들을 찾아 다짐별 진행 현황을 보여준다. /// 주간/월간 다짐은 "그 날짜까지의 누적"으로 계산해 과거 일기에서도 그날 기준 수치가 된다. private struct DiarySummaryPage: View { @Bindable var entry: DiaryEntry let showMood: Bool @Environment(\.modelContext) private var context @Query private var sessions: [TimeSession] @Query private var countEntries: [CountEntry] @Query(sort: \Action.createdAt) private var allActionsQuery: [Action] @Query(sort: \Goal.sortOrder) private var allGoals: [Goal] @AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = "" private var math: DayMath { DayMath() } 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 } } /// 그날의 기록 스냅숏 (이미지 내보내기와 동일한 데이터 — 24시간 타임테이블 + 기록 목록) private var daySnapshot: ExportSnapshot { ExportBuilder.history( dayKey: entry.dayKey, weeklyTimetable: false, sessions: sessions, entries: countEntries, orderedActions: orderedActions, excludedActionIDs: [], math: math, trimHours: false ) } /// 꼬리표별 시간/횟수 막대 (하루 통계에서 발췌) 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 wide = geo.size.width >= 720 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 showMood { DiaryMoodCard(entry: entry) } ExportHeroRow(stats: daySnapshot.hero) if wide { HStack(alignment: .top, spacing: 16) { // 왼쪽: 24시간 타임테이블 (세로로 길어 한 컬럼을 전담) VStack(spacing: 14) { timetableSections(innerWidth: timetableWidth - 28) } .frame(width: timetableWidth) // 오른쪽: 목표·다짐 현황 → 할 일 → 기록 상세 → 통계 막대 VStack(spacing: 14) { DiaryGoalsCard( goals: activeGoals, dayKey: entry.dayKey, referenceNow: referenceNow, math: math ) DiaryTodoCard(entry: entry) recordSections(innerWidth: rightWidth - 28) barSections(innerWidth: rightWidth - 28) } } } else { DiaryGoalsCard( goals: activeGoals, dayKey: entry.dayKey, referenceNow: referenceNow, math: math ) DiaryTodoCard(entry: entry) timetableSections(innerWidth: fullWidth - 28) recordSections(innerWidth: fullWidth - 28) barSections(innerWidth: fullWidth - 28) } } .padding(20) } } } 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 { ForEach(Array(daySnapshot.sections.enumerated()), id: \.offset) { _, section in if case .timetable = section { ExportSectionView(section: section, innerWidth: innerWidth) } } } @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 dayKey: Date let referenceNow: Date let math: DayMath var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("이 날의 목표") .font(.subheadline.weight(.semibold)) Spacer() Text(String(localized: "\(goals.count)개 진행 중")) .font(.caption2) .foregroundStyle(.secondary) } if goals.isEmpty { 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) Text("\(Int((dayRatio * 100).rounded()))%") .font(.footnote.weight(.bold).monospacedDigit()) .foregroundStyle(goal.color) } // 목표 단위 하루 진행률 (다짐 진행률 평균) 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 = dayRange context = String(localized: "하루 목표") } 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 DiaryMoodCard: View { @Bindable var entry: DiaryEntry @Environment(\.modelContext) private var context @State private var showingEmojiPicker = false @State private var photoSelection: PhotosPickerItem? static let emojiChoices = [ "😀", "🙂", "😌", "🥰", "🥳", "🤗", "😴", "😐", "😔", "😢", "😡", "😤", "🤒", "🤯", "😱", "🙃", ] var body: some View { HStack(spacing: 14) { VStack(alignment: .leading, spacing: 3) { Text("오늘 기분") .font(.subheadline.weight(.semibold)) Text("이모지나 사진으로 오늘의 기분을 남겨 보세요") .font(.caption2) .foregroundStyle(.secondary) } Spacer() // 이모지 선택 Button { showingEmojiPicker = true } label: { Group { if entry.moodEmoji.isEmpty { Image(systemName: "face.smiling") .font(.system(size: 26)) .foregroundStyle(.secondary) } else { Text(entry.moodEmoji) .font(.system(size: 34)) } } .frame(width: 60, height: 60) .background(AppTheme.background, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) } .buttonStyle(.plain) .popover(isPresented: $showingEmojiPicker) { emojiPicker } // 사진 선택 PhotosPicker(selection: $photoSelection, matching: .images) { Group { if let data = entry.moodImageData, let image = UIImage(data: data) { Image(uiImage: image) .resizable() .scaledToFill() } else { Image(systemName: "photo.badge.plus") .font(.system(size: 24)) .foregroundStyle(.secondary) } } .frame(width: 60, height: 60) .background(AppTheme.background) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } .buttonStyle(.plain) if !entry.moodEmoji.isEmpty || entry.moodImageData != nil { Button { entry.moodEmoji = "" entry.moodImageData = nil touch() } label: { Image(systemName: "xmark.circle.fill") .font(.title3) .foregroundStyle(.secondary) } .buttonStyle(.plain) } } .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 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 var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("오늘 할 일") .font(.subheadline.weight(.semibold)) Spacer() 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 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) TextField("할 일", text: $todo.text) .font(.subheadline) .strikethrough(todo.isDone, color: .secondary) .foregroundStyle(todo.isDone ? .secondary : .primary) .onSubmit { try? context.save() } Button(action: onDelete) { Image(systemName: "xmark") .font(.caption2.weight(.semibold)) .foregroundStyle(.tertiary) .padding(4) .contentShape(Rectangle()) } .buttonStyle(.plain) } } }