mycode/myApp/HaruDanim/IOS/Views/DiaryView.swift
songyc macbook 40d917d4e2 feat(diary): add iPad-only premium diary tab with calendar, pencil notes, and export
- 달력(작성일 마커: 기분 이모지/연필 점) → 날짜별 일기, 이번 달 작성 수 표시
- 첫 페이지: 날짜·오늘 기분(이모지/사진, 끄기 옵션)·할 일 체크리스트(스크리블 입력)·
  타임테이블·통계(내보내기 스냅숏 재사용으로 화면·내보내기 일치)
- 노트 페이지: PencilKit 캔버스(도구 팔레트) + 줄 노트 배경 + 사진/도형(사각형·원·화살표·선)
  배치 모드(드래그·핀치), 페이지 무한 추가 — 논리 크기 768×1024 고정으로 WYSIWYG 내보내기
- 내보내기: 구간/복수 날짜(MultiDatePicker) → PDF 1개 또는 날짜별 세로 스티치 PNG
- SwiftData 모델 4종(CloudKit 호환 패턴) 스키마 추가, PremiumFeature.diary,
  iPhone 탭 컨텍스트(탭바·더보기·라디얼)에서 일기 제외, en/ja 번역 39건
- 검증 인자: -diarySeed/-diaryOpenToday/-diaryPage N/-diaryShowExport/-diaryExportRun pdf|image

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-13 02:51:30 +09:00

790 lines
28 KiB
Swift

//
// 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<DiaryEntry>(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: - ( )
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]
@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 daySnapshot: ExportSnapshot {
ExportBuilder.history(
dayKey: entry.dayKey,
weeklyTimetable: false,
sessions: sessions,
entries: countEntries,
orderedActions: orderedActions,
excludedActionIDs: [],
math: math
)
}
/// / ( )
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 sideWidth: CGFloat = 340
let leftWidth = wide
? geo.size.width - sideWidth - 16 - 40
: 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) {
VStack(spacing: 14) {
leftSections(innerWidth: leftWidth - 28)
}
VStack(spacing: 14) {
DiaryTodoCard(entry: entry)
rightSections(innerWidth: sideWidth - 28)
}
.frame(width: sideWidth)
}
} else {
DiaryTodoCard(entry: entry)
leftSections(innerWidth: leftWidth - 28)
rightSections(innerWidth: leftWidth - 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 leftSections(innerWidth: CGFloat) -> some View {
ForEach(Array(daySnapshot.sections.enumerated()), id: \.offset) { _, section in
if case .timetable = section {
ExportSectionView(section: section, innerWidth: innerWidth)
}
}
ForEach(Array(statBars.enumerated()), id: \.offset) { _, section in
ExportSectionView(section: section, innerWidth: innerWidth)
}
if daySnapshot.sections.isEmpty && statBars.isEmpty {
Text("아직 오늘의 기록이 없어요")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
/// () :
@ViewBuilder
private func rightSections(innerWidth: CGFloat) -> some View {
ForEach(Array(daySnapshot.sections.enumerated()), id: \.offset) { _, section in
if case .records = section {
ExportSectionView(section: section, innerWidth: innerWidth)
}
}
}
}
// 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)
}
}
}