diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index 0d5625f..a422f9f 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -185,10 +185,15 @@ final class AppRouter { // MARK: - 탭 정의 enum AppTab: String, CaseIterable, Identifiable { - case main, action, tag, goal, history, stats, settings + case main, action, tag, goal, history, stats, diary, settings static let moreTabValue = "more" + /// iPhone에 노출되는 탭 (일기는 iPad 전용 — 탭바/더보기/라디얼 메뉴에서 제외) + static var phoneCases: [AppTab] { + allCases.filter { $0 != .diary } + } + var id: String { rawValue } var label: String { @@ -199,6 +204,7 @@ enum AppTab: String, CaseIterable, Identifiable { case .goal: return String(localized: "목표") case .history: return String(localized: "기록") case .stats: return String(localized: "통계") + case .diary: return String(localized: "일기") case .settings: return String(localized: "설정") } } @@ -211,6 +217,7 @@ enum AppTab: String, CaseIterable, Identifiable { case .goal: return "flag.checkered" case .history: return "calendar" case .stats: return "chart.xyaxis.line" + case .diary: return "book.closed.fill" case .settings: return "gearshape.fill" } } @@ -218,7 +225,7 @@ enum AppTab: String, CaseIterable, Identifiable { /// 저장 문자열("main,action,history")을 스펙 순서를 유지한 탭 배열로 변환 static func visibleTabs(from raw: String) -> [AppTab] { let chosen = Set(raw.split(separator: ",").map(String.init)) - let tabs = AppTab.allCases.filter { chosen.contains($0.rawValue) } + let tabs = AppTab.phoneCases.filter { chosen.contains($0.rawValue) } return tabs.isEmpty ? [.main] : Array(tabs.prefix(TabBarConfig.maxVisible)) } @@ -232,10 +239,10 @@ enum AppTab: String, CaseIterable, Identifiable { result.append(tab) } } - for tab in AppTab.allCases where !seen.contains(tab) { + for tab in AppTab.phoneCases where !seen.contains(tab) { result.append(tab) } - return result + return result.filter { $0 != .diary } } @ViewBuilder @@ -247,6 +254,7 @@ enum AppTab: String, CaseIterable, Identifiable { case .goal: GoalListView() case .history: HistoryView() case .stats: StatsTabView() + case .diary: DiaryRootView() case .settings: SettingsView() } } @@ -264,7 +272,7 @@ struct MainTabView: View { } private var moreTabs: [AppTab] { - AppTab.allCases.filter { !visibleTabs.contains($0) } + AppTab.phoneCases.filter { !visibleTabs.contains($0) } } var body: some View { diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index 554afcd..47c9640 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -955,6 +955,22 @@ } } }, + "‘오늘 기분’ 영역 표시": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show 'Today's Mood' section" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「今日の気分」欄を表示" + } + } + } + }, "① 대상 선택": { "localizations": { "en": { @@ -1115,6 +1131,22 @@ } } }, + "결과물은 일기 상세와 같은 구성(요약 + 노트 페이지)으로 만들어져요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The result matches the diary layout (summary + note pages)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "書き出し結果は日記詳細と同じ構成(サマリー+ノートページ)になります。" + } + } + } + }, "공유": { "localizations": { "en": { @@ -1147,6 +1179,22 @@ } } }, + "구간 지정": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Date range" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "期間指定" + } + } + } + }, "구독은 언제든 해지할 수 있고, 평생 이용권은 한 번 결제로 모든 기능을 계속 사용해요.": { "localizations": { "en": { @@ -1538,6 +1586,22 @@ } } }, + "까지": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "To" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了" + } + } + } + }, "꼬리표": { "localizations": { "en": { @@ -1842,6 +1906,38 @@ } } }, + "날짜마다 페이지들을 세로로 이어 붙인 PNG 이미지를 만들어요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Creates one PNG per day with its pages stitched vertically." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日付ごとにページを縦につなげたPNG画像を作成します。" + } + } + } + }, + "내보내기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Export" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "書き出す" + } + } + } + }, "내비게이션": { "localizations": { "en": { @@ -1906,6 +2002,22 @@ } } }, + "다시 만들기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Regenerate" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作り直す" + } + } + } + }, "다시 시도": { "localizations": { "en": { @@ -2515,6 +2627,22 @@ } } }, + "도형": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shape" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "図形" + } + } + } + }, "독서": { "localizations": { "en": { @@ -2611,6 +2739,22 @@ } } }, + "만드는 중…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Creating…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作成中…" + } + } + } + }, "매달": { "localizations": { "en": { @@ -3488,6 +3632,22 @@ } } }, + "방식": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "方式" + } + } + } + }, "방향": { "localizations": { "en": { @@ -3504,6 +3664,22 @@ } } }, + "배치 모드": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Arrange mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "配置モード" + } + } + } + }, "배치 편집": { "localizations": { "en": { @@ -3632,6 +3808,22 @@ } } }, + "부터": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "From" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開始" + } + } + } + }, "분": { "localizations": { "en": { @@ -3648,6 +3840,22 @@ } } }, + "사각형": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rectangle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "四角形" + } + } + } + }, "사용 안 함": { "localizations": { "en": { @@ -3680,6 +3888,22 @@ } } }, + "사진": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Photo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "写真" + } + } + } + }, "사진 앨범에 저장했어요.": { "localizations": { "en": { @@ -3808,6 +4032,22 @@ } } }, + "새 페이지": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New page" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいページ" + } + } + } + }, "색": { "localizations": { "en": { @@ -3840,6 +4080,22 @@ } } }, + "선": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Line" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "直線" + } + } + } + }, "선택": { "localizations": { "en": { @@ -4433,6 +4689,38 @@ } } }, + "아직 오늘의 기록이 없어요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No records for this day yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "まだこの日の記録がありません" + } + } + } + }, + "애플 펜슬로 자유롭게 쓰고 그려 보세요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Write and draw freely with Apple Pencil" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Apple Pencilで自由に書いたり描いたりできます" + } + } + } + }, "앱 설정과 일치": { "localizations": { "en": { @@ -4593,6 +4881,22 @@ } } }, + "오늘 기분": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Today's Mood" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日の気分" + } + } + } + }, "오늘 누적": { "localizations": { "en": { @@ -4609,6 +4913,22 @@ } } }, + "오늘 할 일": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Today's To-Dos" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日のやること" + } + } + } + }, "오른쪽 위 + 버튼으로 꼬리표를 추가하세요.": { "localizations": { "en": { @@ -4785,6 +5105,22 @@ } } }, + "원": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ellipse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "円" + } + } + } + }, "원형 게이지": { "localizations": { "en": { @@ -4946,6 +5282,22 @@ } } }, + "이 페이지 삭제": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete this page" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このページを削除" + } + } + } + }, "이동": { "localizations": { "en": { @@ -4978,6 +5330,38 @@ } } }, + "이모지나 사진으로 오늘의 기분을 남겨 보세요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Capture today's mood with an emoji or photo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "絵文字や写真で今日の気分を残しましょう" + } + } + } + }, + "이미지": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像" + } + } + } + }, "이미지 생성 중…": { "localizations": { "en": { @@ -5058,6 +5442,22 @@ } } }, + "이번 달에 쓴 일기 %lld편": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld diary entries this month" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今月書いた日記 %lld件" + } + } + } + }, "이번 주": { "localizations": { "en": { @@ -5187,6 +5587,86 @@ } } }, + "일기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Diary" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日記" + } + } + } + }, + "일기 내보내기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Export Diary" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日記を書き出す" + } + } + } + }, + "일기 페이지들을 담은 PDF 문서 1개를 만들어요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Creates one PDF document containing the diary pages." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日記ページをまとめたPDF文書を1つ作成します。" + } + } + } + }, + "일기는 프리미엄 기능이에요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Diary is a premium feature" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日記はプレミアム機能です" + } + } + } + }, + "일기를 쓴 날 %lld일이 포함돼요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Includes %lld days with diary entries" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日記を書いた日が%lld日含まれます" + } + } + } + }, "일주일": { "localizations": { "en": { @@ -5715,6 +6195,22 @@ } } }, + "줄 노트": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lined paper" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "罫線ノート" + } + } + } + }, "중형 위젯 구성": { "localizations": { "en": { @@ -6340,6 +6836,22 @@ } } }, + "파일 %lld개 공유": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Share %lld files" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル%lld個を共有" + } + } + } + }, "팔굽혀펴기": { "localizations": { "en": { @@ -6356,6 +6868,38 @@ } } }, + "페이지 추가": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add page" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページを追加" + } + } + } + }, + "펜슬이나 키보드로 할 일을 적고,\n체크 표시로 완료해 보세요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jot down to-dos with Pencil or keyboard,\nthen check them off" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペンシルやキーボードでやることを書き、\nチェックで完了しましょう" + } + } + } + }, "표시 방식": { "localizations": { "en": { @@ -6597,6 +7141,22 @@ } } }, + "하루 다님 일기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Haru Danim Diary" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Haru Danim 日記" + } + } + } + }, "하루 단위": { "localizations": { "en": { @@ -6677,6 +7237,22 @@ } } }, + "하루의 기록·통계와 함께 애플 펜슬로 쓰는\n나만의 다이어리를 만들 수 있어요.\n설정 → 프리미엄에서 잠금 해제해 주세요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Build your own diary with Apple Pencil,\nalongside your daily records and stats.\nUnlock it in Settings → Premium." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1日の記録・統計と一緒にApple Pencilで書く\n自分だけのダイアリーを作れます。\n設定 → プレミアムでロック解除してください。" + } + } + } + }, "학습": { "localizations": { "en": { @@ -6821,6 +7397,22 @@ } } }, + "할 일": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "To-do" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "やること" + } + } + } + }, "합계": { "localizations": { "en": { @@ -7281,6 +7873,22 @@ } } }, + "형식": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Format" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "形式" + } + } + } + }, "화": { "comment": "요일 축약(화요일)", "localizations": { @@ -7314,6 +7922,22 @@ } } }, + "화살표": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Arrow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "矢印" + } + } + } + }, "확인": { "localizations": { "en": { diff --git a/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift b/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift new file mode 100644 index 0000000..dd3ebc8 --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift @@ -0,0 +1,411 @@ +// +// DiaryExportSheet.swift +// Haru_Danim +// +// 일기 내보내기 — 날짜 구간(부터~까지) 또는 복수 날짜 지정(1개만도 가능)으로 +// 해당 날짜들의 일기를 PDF 또는 이미지(PNG)로 만들어 공유한다. +// - PDF: 일기 페이지(요약 + 노트들)를 각각 한 페이지로 담은 문서 1개 +// - 이미지: 날짜마다 페이지들을 세로로 이어 붙인 PNG 1장씩 +// 렌더는 화면과 동일한 논리 크기(요약 768pt 폭 / 노트 768×1024pt)에서 2배율로 수행한다. +// + +import SwiftUI +import SwiftData +import PencilKit +import UIKit + +struct DiaryExportSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var context + + @Query private var diaryEntries: [DiaryEntry] + @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 enum SelectionMode: String, CaseIterable, Identifiable { + case range, picks + var id: String { rawValue } + var label: String { + switch self { + case .range: return String(localized: "구간 지정") + case .picks: return String(localized: "날짜 지정") + } + } + } + + private enum ExportFormat: String, CaseIterable, Identifiable { + case pdf, image + var id: String { rawValue } + var label: String { + switch self { + case .pdf: return "PDF" + case .image: return String(localized: "이미지") + } + } + } + + @State private var mode: SelectionMode = .range + @State private var startDate: Date = Calendar.current.date(byAdding: .day, value: -6, to: .now)! + @State private var endDate: Date = .now + @State private var picks: Set = [] + @State private var format: ExportFormat = .pdf + @State private var isRendering = false + @State private var resultURLs: [URL] = [] + + private var math: DayMath { DayMath() } + + private var orderedActions: [Action] { + LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw) + } + + /// 선택 조건에 해당하고 내용이 있는 일기 (날짜순) + private var targetEntries: [DiaryEntry] { + let keys: Set + switch mode { + case .range: + let startKey = math.dayKey(for: min(startDate, endDate)) + let endKey = math.dayKey(for: max(startDate, endDate)) + keys = Set(diaryEntries.map(\.dayKey).filter { $0 >= startKey && $0 <= endKey }) + case .picks: + keys = Set(picks.compactMap { components in + math.calendar.date(from: components).map { math.dayKey(for: $0) } + }) + } + return diaryEntries + .filter { keys.contains($0.dayKey) && $0.hasContent } + .sorted { $0.dayKey < $1.dayKey } + } + + var body: some View { + NavigationStack { + Form { + Section("날짜 선택") { + Picker("방식", selection: $mode) { + ForEach(SelectionMode.allCases) { mode in + Text(mode.label).tag(mode) + } + } + .pickerStyle(.segmented) + + switch mode { + case .range: + DatePicker("부터", selection: $startDate, displayedComponents: .date) + DatePicker("까지", selection: $endDate, displayedComponents: .date) + case .picks: + MultiDatePicker("날짜", selection: $picks) + .frame(minHeight: 300) + .tint(AppTheme.green) + } + + Label { + Text("일기를 쓴 날 \(targetEntries.count)일이 포함돼요") + } icon: { + Image(systemName: "pencil.and.scribble") + } + .font(.footnote) + .foregroundStyle(targetEntries.isEmpty ? AnyShapeStyle(.secondary) : AnyShapeStyle(AppTheme.green)) + } + + Section("형식") { + Picker("형식", selection: $format) { + ForEach(ExportFormat.allCases) { format in + Text(format.label).tag(format) + } + } + .pickerStyle(.segmented) + Text(format == .pdf + ? "일기 페이지들을 담은 PDF 문서 1개를 만들어요." + : "날짜마다 페이지들을 세로로 이어 붙인 PNG 이미지를 만들어요.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + if isRendering { + HStack { + ProgressView() + Text("만드는 중…") + .foregroundStyle(.secondary) + } + } else if resultURLs.isEmpty { + Button { + export() + } label: { + Label("내보내기", systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity) + } + .disabled(targetEntries.isEmpty) + } else { + ShareLink(items: resultURLs) { + Label("파일 \(resultURLs.count)개 공유", systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity) + } + Button("다시 만들기") { + resultURLs = [] + } + .frame(maxWidth: .infinity) + } + } footer: { + Text("결과물은 일기 상세와 같은 구성(요약 + 노트 페이지)으로 만들어져요.") + } + } + .tint(AppTheme.green) + .scrollContentBackground(.hidden) + .background(AppTheme.background) + .navigationTitle("일기 내보내기") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("닫기") { dismiss() } + } + } + } + .onAppear { + #if DEBUG + // 검증용: -diaryExportRun pdf|image → 자동 내보내기 후 Documents에 결과 복사 + if let raw = UserDefaults.standard.string(forKey: "diaryExportRun") { + format = raw == "image" ? .image : .pdf + export() + } + #endif + } + // 조건이 바뀌면 이전 결과를 무효화 + .onChange(of: mode) { resultURLs = [] } + .onChange(of: startDate) { resultURLs = [] } + .onChange(of: endDate) { resultURLs = [] } + .onChange(of: picks) { resultURLs = [] } + .onChange(of: format) { resultURLs = [] } + } + + // MARK: 렌더링 + + private func export() { + isRendering = true + let entries = targetEntries + Task { @MainActor in + // 시트 UI가 스피너를 그릴 틈을 준다 (렌더 자체는 메인에서 수행) + try? await Task.sleep(for: .milliseconds(60)) + var urls: [URL] = [] + switch format { + case .pdf: + if let url = renderPDF(entries) { urls = [url] } + case .image: + urls = entries.compactMap { renderStitchedImage($0) } + } + resultURLs = urls + isRendering = false + #if DEBUG + if UserDefaults.standard.string(forKey: "diaryExportRun") != nil, + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + for url in urls { + let dest = docs.appendingPathComponent(url.lastPathComponent) + try? FileManager.default.removeItem(at: dest) + try? FileManager.default.copyItem(at: url, to: dest) + } + } + #endif + } + } + + private func slugDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private var fileSlug: String { + let entries = targetEntries + guard let first = entries.first else { return "일기" } + if entries.count == 1 { return "일기-\(slugDate(first.dayKey))" } + return "일기-\(slugDate(first.dayKey))~\(slugDate(entries.last!.dayKey))" + } + + /// 한 일기의 모든 페이지를 이미지로 (요약 + 노트들) + @MainActor + private func renderPages(_ entry: DiaryEntry) -> [UIImage] { + var images: [UIImage] = [] + let snapshot = ExportBuilder.history( + dayKey: entry.dayKey, weeklyTimetable: false, + sessions: sessions, entries: countEntries, + orderedActions: orderedActions, excludedActionIDs: [], math: math + ) + let summary = DiaryPrintSummaryView(entry: entry, snapshot: snapshot) + let summaryRenderer = ImageRenderer(content: summary) + summaryRenderer.scale = 2 + summaryRenderer.isOpaque = true + if let image = summaryRenderer.uiImage { + images.append(image) + } + for page in entry.pages where page.hasContent { + let size = DiaryPageMetrics.size + var drawingImage: UIImage? + if !page.drawingData.isEmpty, let drawing = try? PKDrawing(data: page.drawingData) { + drawingImage = drawing.image(from: CGRect(origin: .zero, size: size), scale: 2) + } + let view = DiaryPrintNotePageView(page: page, drawingImage: drawingImage) + let renderer = ImageRenderer(content: view) + renderer.scale = 2 + renderer.isOpaque = true + if let image = renderer.uiImage { + images.append(image) + } + } + return images + } + + /// PDF 1개 — 일기 페이지마다 PDF 한 페이지 (페이지 크기는 내용 크기 그대로) + @MainActor + private func renderPDF(_ entries: [DiaryEntry]) -> URL? { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("하루다님-\(fileSlug).pdf") + let bounds = CGRect(origin: .zero, size: DiaryPageMetrics.size) + let renderer = UIGraphicsPDFRenderer(bounds: bounds) + do { + try renderer.writePDF(to: url) { pdf in + for entry in entries { + for image in renderPages(entry) { + let pageWidth = DiaryPageMetrics.size.width + let pageHeight = image.size.height * pageWidth / image.size.width + let pageBounds = CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight) + pdf.beginPage(withBounds: pageBounds, pageInfo: [:]) + image.draw(in: pageBounds) + } + } + } + return url + } catch { + return nil + } + } + + /// 날짜 하나 → 페이지들을 세로로 이어 붙인 PNG + @MainActor + private func renderStitchedImage(_ entry: DiaryEntry) -> URL? { + let images = renderPages(entry) + guard !images.isEmpty else { return nil } + let width = DiaryPageMetrics.size.width + let gap: CGFloat = 20 + let heights = images.map { $0.size.height * width / $0.size.width } + let totalHeight = heights.reduce(0, +) + gap * CGFloat(images.count - 1) + + let rendererFormat = UIGraphicsImageRendererFormat() + rendererFormat.scale = 2 + let renderer = UIGraphicsImageRenderer( + size: CGSize(width: width, height: totalHeight), format: rendererFormat + ) + let stitched = renderer.image { ctx in + UIColor(AppTheme.background).setFill() + ctx.fill(CGRect(x: 0, y: 0, width: width, height: totalHeight)) + var y: CGFloat = 0 + for (image, height) in zip(images, heights) { + image.draw(in: CGRect(x: 0, y: y, width: width, height: height)) + y += height + gap + } + } + guard let data = stitched.pngData() else { return nil } + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("하루다님-일기-\(slugDate(entry.dayKey)).png") + try? data.write(to: url, options: .atomic) + return url + } +} + +// MARK: - 인쇄용 요약 페이지 (768pt 고정 폭) + +struct DiaryPrintSummaryView: View { + let entry: DiaryEntry + let snapshot: ExportSnapshot + + private let width: CGFloat = 768 + private var innerWidth: CGFloat { width - 40 - 28 } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 3) { + Text(Format.fullDate(entry.dayKey)) + .font(.system(size: 26, weight: .bold)) + Text("하루 다님 일기") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + if !entry.moodEmoji.isEmpty { + Text(entry.moodEmoji) + .font(.system(size: 44)) + } + if let data = entry.moodImageData, let image = UIImage(data: data) { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 64, height: 64) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } + + if !entry.todos.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("오늘 할 일") + .font(.subheadline.weight(.semibold)) + ForEach(entry.todos, id: \.uuid) { todo in + HStack(spacing: 8) { + Image(systemName: todo.isDone ? "checkmark.circle.fill" : "circle") + .foregroundStyle(todo.isDone ? AnyShapeStyle(AppTheme.green) : AnyShapeStyle(.secondary)) + Text(todo.text) + .font(.footnote) + .strikethrough(todo.isDone, color: .secondary) + .foregroundStyle(todo.isDone ? .secondary : .primary) + } + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + if !snapshot.hero.isEmpty { + ExportHeroRow(stats: snapshot.hero) + } + ForEach(Array(snapshot.sections.enumerated()), id: \.offset) { _, section in + ExportSectionView(section: section, innerWidth: innerWidth) + } + } + .padding(20) + .frame(width: width, alignment: .leading) + .background(AppTheme.background) + .environment(\.colorScheme, .light) + } +} + +// MARK: - 인쇄용 노트 페이지 (768×1024 고정) + +struct DiaryPrintNotePageView: View { + let page: DiaryPage + let drawingImage: UIImage? + + var body: some View { + ZStack { + Rectangle().fill(AppTheme.surface) + if page.lined { + DiaryLinedPaper() + } + ForEach(page.items, id: \.uuid) { item in + DiaryItemView( + item: item, + pageSize: DiaryPageMetrics.size, + arranging: false, + selectedID: .constant(nil) + ) + } + if let drawingImage { + Image(uiImage: drawingImage) + .resizable() + .scaledToFit() + } + } + .frame(width: DiaryPageMetrics.size.width, height: DiaryPageMetrics.size.height) + .environment(\.colorScheme, .light) + } +} diff --git a/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift b/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift new file mode 100644 index 0000000..7dc4c51 --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift @@ -0,0 +1,407 @@ +// +// DiaryNotePage.swift +// Haru_Danim +// +// 일기 노트 페이지 — 애플 펜슬 자유 필기 캔버스 + 사진/도형 배치. +// +// 좌표계: 페이지는 논리 크기 768×1024pt로 고정하고 화면 크기에 맞춰 scaleEffect로 축소/확대한다. +// → 필기(PKDrawing 절대 좌표)와 배치 요소(비율 좌표)가 기기·창 크기와 무관하게 항상 같은 +// 자리에 놓이고, 내보내기(같은 768pt 기준 렌더)와도 픽셀 단위로 일치한다. +// +// 모드: 기본은 필기 모드(캔버스가 터치를 받음), '배치 모드'를 켜면 캔버스 히트테스트를 끄고 +// 사진/도형을 드래그·핀치로 옮기고 크기를 바꿀 수 있다 (선택 시 삭제 버튼 표시). +// + +import SwiftUI +import SwiftData +import PencilKit +import PhotosUI + +/// 페이지 논리 크기 (내보내기 렌더와 공유) +enum DiaryPageMetrics { + static let size = CGSize(width: 768, height: 1024) + static let lineSpacing: CGFloat = 44 + static let lineTopInset: CGFloat = 72 +} + +struct DiaryNotePageView: View { + @Bindable var page: DiaryPage + /// 현재 보이는 페이지일 때만 캔버스가 퍼스트 리스폰더(펜슬 도구 팔레트)를 가진다 + let isActive: Bool + + @Environment(\.modelContext) private var context + + @State private var arranging = false + @State private var selectedItemID: UUID? + @State private var photoSelection: PhotosPickerItem? + + var body: some View { + VStack(spacing: 10) { + pageToolbar + GeometryReader { geo in + let scale = min(geo.size.width / DiaryPageMetrics.size.width, + geo.size.height / DiaryPageMetrics.size.height) + pageContent + .frame(width: DiaryPageMetrics.size.width, height: DiaryPageMetrics.size.height) + .scaleEffect(scale) + .frame(width: geo.size.width, height: geo.size.height) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 34) + .onChange(of: photoSelection) { + guard let photoSelection else { return } + Task { + if let data = try? await photoSelection.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + addPhoto(image) + } + self.photoSelection = nil + } + } + } + + // MARK: 페이지 도구 줄 + + private var pageToolbar: some View { + HStack(spacing: 12) { + Toggle(isOn: $page.lined.animation()) { + Label("줄 노트", systemImage: "text.justify") + } + .toggleStyle(.button) + .onChange(of: page.lined) { save() } + + PhotosPicker(selection: $photoSelection, matching: .images) { + Label("사진", systemImage: "photo.badge.plus") + } + + Menu { + shapeButton(.rectangle, label: String(localized: "사각형"), symbol: "rectangle") + shapeButton(.ellipse, label: String(localized: "원"), symbol: "circle") + shapeButton(.arrow, label: String(localized: "화살표"), symbol: "arrow.right") + shapeButton(.line, label: String(localized: "선"), symbol: "minus") + } label: { + Label("도형", systemImage: "square.on.circle") + } + + Spacer() + + if arranging, let selectedItemID, + let item = page.items.first(where: { $0.uuid == selectedItemID }) { + Button(role: .destructive) { + deleteItem(item) + } label: { + Label("삭제", systemImage: "trash") + } + } + + Toggle(isOn: $arranging.animation()) { + Label("배치 모드", systemImage: "hand.draw") + } + .toggleStyle(.button) + .onChange(of: arranging) { + if !arranging { selectedItemID = nil } + } + } + .font(.subheadline) + .tint(AppTheme.green) + .padding(.horizontal, 4) + .frame(maxWidth: DiaryPageMetrics.size.width) + } + + private func shapeButton(_ kind: DiaryItemKind, label: String, symbol: String) -> some View { + Button { + addShape(kind) + } label: { + Label(label, systemImage: symbol) + } + } + + // MARK: 페이지 본체 (768×1024 고정 좌표) + + private var pageContent: some View { + ZStack { + // 종이 + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(AppTheme.surface) + .shadow(color: .black.opacity(0.10), radius: 14, y: 6) + if page.lined { + DiaryLinedPaper() + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + + // 배치 요소 (사진/도형) — 필기 아래에 깔린다 + ForEach(page.items, id: \.uuid) { item in + DiaryItemView( + item: item, + pageSize: DiaryPageMetrics.size, + arranging: arranging, + selectedID: $selectedItemID, + onCommit: save + ) + } + + // 필기 캔버스 — 항상 최상단. 배치 모드에서는 터치를 요소들에게 넘긴다. + DiaryPencilCanvas( + drawingData: page.drawingData, + isActive: isActive && !arranging + ) { data in + page.drawingData = data + save() + } + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .allowsHitTesting(!arranging) + + if arranging { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(AppTheme.yellow, style: StrokeStyle(lineWidth: 2, dash: [7, 5])) + } + } + .contentShape(Rectangle()) + .onTapGesture { + if arranging { selectedItemID = nil } + } + } + + // MARK: 요소 추가/삭제 + + private func addPhoto(_ image: UIImage) { + let item = DiaryPageItem(kind: .photo) + item.imageData = image.diaryCompressed() + item.page = page + context.insert(item) + arranging = true + selectedItemID = item.uuid + save() + } + + private func addShape(_ kind: DiaryItemKind) { + let item = DiaryPageItem(kind: kind) + item.colorHex = AppTheme.tagPresets.randomElement() ?? "#2F6B4F" + item.page = page + context.insert(item) + arranging = true + selectedItemID = item.uuid + save() + } + + private func deleteItem(_ item: DiaryPageItem) { + context.delete(item) + selectedItemID = nil + save() + } + + private func save() { + page.entry?.updatedAt = .now + try? context.save() + } +} + +// MARK: - 줄 노트 배경 + +struct DiaryLinedPaper: View { + var body: some View { + Canvas { canvasContext, size in + var y = DiaryPageMetrics.lineTopInset + while y < size.height - 24 { + var path = Path() + path.move(to: CGPoint(x: 32, y: y)) + path.addLine(to: CGPoint(x: size.width - 32, y: y)) + canvasContext.stroke(path, with: .color(.primary.opacity(0.10)), lineWidth: 1) + y += DiaryPageMetrics.lineSpacing + } + } + } +} + +// MARK: - 배치 요소 (사진/도형) + +struct DiaryItemView: View { + @Bindable var item: DiaryPageItem + let pageSize: CGSize + let arranging: Bool + @Binding var selectedID: UUID? + var onCommit: () -> Void = {} + + @State private var dragTranslation: CGSize = .zero + @State private var pinchScale: CGFloat = 1 + + private var isSelected: Bool { selectedID == item.uuid } + + private var width: CGFloat { + pageSize.width * item.widthRatio * pinchScale + } + + private var height: CGFloat { + if item.kind == .photo, + let data = item.imageData, let image = UIImage(data: data), image.size.width > 0 { + return width * image.size.height / image.size.width + } + return width * item.kind.defaultAspect + } + + var body: some View { + content + .frame(width: width, height: height) + .overlay { + if arranging && isSelected { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(AppTheme.green, lineWidth: 2) + } + } + .rotationEffect(.degrees(item.rotationDegrees)) + .position( + x: pageSize.width * item.centerX + dragTranslation.width, + y: pageSize.height * item.centerY + dragTranslation.height + ) + .allowsHitTesting(arranging) + .onTapGesture { selectedID = item.uuid } + .gesture(arranging ? moveAndResize : nil) + } + + @ViewBuilder + private var content: some View { + switch item.kind { + case .photo: + if let data = item.imageData, let image = UIImage(data: data) { + Image(uiImage: image) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 5, y: 2) + } else { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.2)) + } + case .rectangle: + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color(hex: item.colorHex), lineWidth: 5) + case .ellipse: + Ellipse() + .strokeBorder(Color(hex: item.colorHex), lineWidth: 5) + case .arrow: + DiaryArrowShape() + .stroke(Color(hex: item.colorHex), + style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round)) + case .line: + DiaryLineShape() + .stroke(Color(hex: item.colorHex), + style: StrokeStyle(lineWidth: 5, lineCap: .round)) + } + } + + /// 드래그(이동) + 핀치(크기) 동시 제스처. 끝났을 때만 모델에 반영한다. + private var moveAndResize: some Gesture { + let drag = DragGesture() + .onChanged { value in + selectedID = item.uuid + dragTranslation = value.translation + } + .onEnded { value in + item.centerX = min(max(item.centerX + value.translation.width / pageSize.width, 0.02), 0.98) + item.centerY = min(max(item.centerY + value.translation.height / pageSize.height, 0.02), 0.98) + dragTranslation = .zero + onCommit() + } + let pinch = MagnificationGesture() + .onChanged { value in + selectedID = item.uuid + pinchScale = value + } + .onEnded { value in + item.widthRatio = min(max(item.widthRatio * value, 0.08), 0.95) + pinchScale = 1 + onCommit() + } + return drag.simultaneously(with: pinch) + } +} + +struct DiaryArrowShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let midY = rect.midY + path.move(to: CGPoint(x: rect.minX, y: midY)) + path.addLine(to: CGPoint(x: rect.maxX, y: midY)) + let head = min(rect.width * 0.22, rect.height * 0.9) + path.move(to: CGPoint(x: rect.maxX - head, y: midY - head * 0.6)) + path.addLine(to: CGPoint(x: rect.maxX, y: midY)) + path.addLine(to: CGPoint(x: rect.maxX - head, y: midY + head * 0.6)) + return path + } +} + +struct DiaryLineShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.midY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY)) + return path + } +} + +// MARK: - PencilKit 캔버스 + +struct DiaryPencilCanvas: UIViewRepresentable { + let drawingData: Data + /// 퍼스트 리스폰더(펜슬 도구 팔레트) 보유 여부 — 보이는 페이지 & 필기 모드일 때만 + let isActive: Bool + let onChange: (Data) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onChange: onChange) + } + + func makeUIView(context: Context) -> PKCanvasView { + let canvas = PKCanvasView() + canvas.backgroundColor = .clear + canvas.isOpaque = false + // 시뮬레이터·손가락 입력도 허용 (실기기에서 펜슬 우선은 시스템이 처리) + canvas.drawingPolicy = .anyInput + canvas.delegate = context.coordinator + if let drawing = try? PKDrawing(data: drawingData), !drawingData.isEmpty { + canvas.drawing = drawing + } + context.coordinator.canvas = canvas + return canvas + } + + func updateUIView(_ canvas: PKCanvasView, context: Context) { + context.coordinator.setActive(isActive) + } + + @MainActor + final class Coordinator: NSObject, PKCanvasViewDelegate { + let onChange: (Data) -> Void + weak var canvas: PKCanvasView? + private let toolPicker = PKToolPicker() + private var observing = false + + init(onChange: @escaping (Data) -> Void) { + self.onChange = onChange + } + + func setActive(_ active: Bool) { + guard let canvas else { return } + if active { + if !observing { + toolPicker.addObserver(canvas) + observing = true + } + toolPicker.setVisible(true, forFirstResponder: canvas) + // 창에 붙기 전엔 실패하므로 다음 런루프에서 재시도 + DispatchQueue.main.async { + if canvas.window != nil { + canvas.becomeFirstResponder() + } + } + } else if canvas.isFirstResponder { + canvas.resignFirstResponder() + } + } + + func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) { + onChange(canvasView.drawing.dataRepresentation()) + } + } +} diff --git a/myApp/HaruDanim/IOS/Views/DiaryView.swift b/myApp/HaruDanim/IOS/Views/DiaryView.swift new file mode 100644 index 0000000..b0dcc8c --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/DiaryView.swift @@ -0,0 +1,789 @@ +// +// 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: - 첫 페이지 (하루 요약) + +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) + } + } +} diff --git a/myApp/HaruDanim/IOS/Views/ExportImageView.swift b/myApp/HaruDanim/IOS/Views/ExportImageView.swift index fbd4bf0..d5dcdc9 100644 --- a/myApp/HaruDanim/IOS/Views/ExportImageView.swift +++ b/myApp/HaruDanim/IOS/Views/ExportImageView.swift @@ -743,16 +743,15 @@ struct ExportPosterView: View { header heroRow if snapshot.sections.isEmpty { - card(String(localized: "기록")) { - Text("표시할 기록이 없어요") - .font(.caption) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 30) - } + Text("표시할 기록이 없어요") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 44) + .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } ForEach(Array(snapshot.sections.enumerated()), id: \.offset) { _, section in - sectionView(section) + ExportSectionView(section: section, innerWidth: innerWidth) } footer } @@ -798,23 +797,7 @@ struct ExportPosterView: View { @ViewBuilder private var heroRow: some View { if !snapshot.hero.isEmpty { - HStack(spacing: 10) { - ForEach(snapshot.hero) { stat in - VStack(alignment: .leading, spacing: 2) { - Text(stat.value) - .font(.system(size: 16, weight: .bold).monospacedDigit()) - .foregroundStyle(AppTheme.green) - .lineLimit(1) - .minimumScaleFactor(0.6) - Text(stat.title) - .font(.caption2) - .foregroundStyle(.secondary) - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - } - } + ExportHeroRow(stats: snapshot.hero) } } @@ -839,10 +822,44 @@ struct ExportPosterView: View { .padding(.top, 6) } - // MARK: 섹션 +} - @ViewBuilder - private func sectionView(_ section: ExportSectionData) -> some View { +// MARK: - 요약 지표 카드 줄 (포스터·일기 요약 공용) + +struct ExportHeroRow: View { + let stats: [ExportHeroStat] + + var body: some View { + HStack(spacing: 10) { + ForEach(stats) { stat in + VStack(alignment: .leading, spacing: 2) { + Text(stat.value) + .font(.system(size: 16, weight: .bold).monospacedDigit()) + .foregroundStyle(AppTheme.green) + .lineLimit(1) + .minimumScaleFactor(0.6) + Text(stat.title) + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } + } +} + +// MARK: - 섹션 카드 렌더러 (포스터·일기 요약 공용) + +struct ExportSectionView: View { + let section: ExportSectionData + /// 카드 안쪽 콘텐츠 폭 (타임테이블처럼 고정 좌표 계산이 필요한 뷰에 전달) + let innerWidth: CGFloat + + private let cardPadding: CGFloat = 14 + + var body: some View { switch section { case .timetable(let title, let subtitle, let data): card(title, subtitle: subtitle) { diff --git a/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift b/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift index 74fbbf2..c23ecdb 100644 --- a/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift +++ b/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift @@ -45,7 +45,7 @@ struct RadialTabView: View { ZStack { // 1) 탭 화면 본체 — 시스템 탭바만 숨기고 TabView가 각 탭의 스택 상태를 보존한다. TabView(selection: $router.tabSelection) { - ForEach(AppTab.allCases) { tab in + ForEach(AppTab.phoneCases) { tab in Tab(tab.label, systemImage: tab.symbol, value: tab.rawValue) { NavigationStack { tab.rootView diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index 35d5460..740a053 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -86,7 +86,7 @@ struct SettingsView: View { // 버블 메뉴는 모든 탭을 항상 노출하므로 노출 탭 선택이 필요 없다 if navStyleRaw == NavStyle.tabBar.rawValue { Section { - ForEach(AppTab.allCases) { tab in + ForEach(AppTab.phoneCases) { tab in tabToggleRow(tab) } } header: { @@ -282,7 +282,7 @@ struct SettingsView: View { current.append(tab) } // 스펙 탭 순서(메인→행동→꼬리표→목표→기록→설정) 유지 - let ordered = AppTab.allCases.filter { current.contains($0) } + let ordered = AppTab.phoneCases.filter { current.contains($0) } visibleTabsRaw = ordered.map(\.rawValue).joined(separator: ",") } } diff --git a/myApp/HaruDanim/Shared/DataStore.swift b/myApp/HaruDanim/Shared/DataStore.swift index 13c324b..926ebc7 100644 --- a/myApp/HaruDanim/Shared/DataStore.swift +++ b/myApp/HaruDanim/Shared/DataStore.swift @@ -26,6 +26,10 @@ nonisolated enum DataStore { CountEntry.self, Goal.self, Quest.self, + DiaryEntry.self, + DiaryTodo.self, + DiaryPage.self, + DiaryPageItem.self, ]) /// iCloud 동기화 on/off (프리미엄, App Group defaults에 저장) diff --git a/myApp/HaruDanim/Shared/DiaryModels.swift b/myApp/HaruDanim/Shared/DiaryModels.swift new file mode 100644 index 0000000..8993be8 --- /dev/null +++ b/myApp/HaruDanim/Shared/DiaryModels.swift @@ -0,0 +1,150 @@ +// +// DiaryModels.swift +// Haru_Danim +// +// 일기(다이어리) 데이터 모델 — iPad 전용 프리미엄 기능. +// 하루(논리적 하루 키)당 DiaryEntry 하나: +// - 첫 페이지(요약): 오늘 기분(이모지 또는 사진) + 할 일 체크리스트 (+ 그날의 기록/통계는 즉석 계산) +// - 이후 페이지: PencilKit 자유 필기 캔버스(줄 노트 배경 선택) + 사진/도형 배치 +// CloudKit 호환 규칙(모든 프로퍼티 기본값, to-many 관계 optional storage + computed 접근자)을 따른다. +// + +import Foundation +import SwiftData + +@Model +final class DiaryEntry { + var uuid: UUID = UUID() + /// 논리적 하루 키 (DayMath.dayKey — 해당 달력일의 자정) + var dayKey: Date = Date() + /// 오늘 기분 이모지 (사진과 동시 사용 가능, 빈 문자열 = 미설정) + var moodEmoji: String = "" + /// 오늘 기분 사진 (사진 앨범에서 선택, nil = 미설정) + @Attribute(.externalStorage) + var moodImageData: Data? = nil + var createdAt: Date = Date() + var updatedAt: Date = Date() + + @Relationship(deleteRule: .cascade, inverse: \DiaryTodo.entry) + var todosStorage: [DiaryTodo]? = [] + + @Relationship(deleteRule: .cascade, inverse: \DiaryPage.entry) + var pagesStorage: [DiaryPage]? = [] + + init(dayKey: Date) { + self.dayKey = dayKey + } + + var todos: [DiaryTodo] { + get { (todosStorage ?? []).sorted { $0.sortOrder < $1.sortOrder } } + set { todosStorage = newValue } + } + + var pages: [DiaryPage] { + get { (pagesStorage ?? []).sorted { $0.index < $1.index } } + set { pagesStorage = newValue } + } + + /// 달력 마커 표시 기준: 무언가 하나라도 기록했으면 "작성함" + var hasContent: Bool { + !moodEmoji.isEmpty || moodImageData != nil + || !(todosStorage ?? []).isEmpty + || (pagesStorage ?? []).contains { $0.hasContent } + } +} + +/// 요약 페이지의 할 일 한 줄 (펜슬 탭으로 체크) +@Model +final class DiaryTodo { + var uuid: UUID = UUID() + var text: String = "" + var isDone: Bool = false + var sortOrder: Int = 0 + var entry: DiaryEntry? + + init(text: String = "", sortOrder: Int = 0) { + self.text = text + self.sortOrder = sortOrder + } +} + +/// 자유 필기 노트 페이지 +@Model +final class DiaryPage { + var uuid: UUID = UUID() + /// 페이지 순서 (0 = 요약 다음 첫 노트) + var index: Int = 0 + /// true = 줄 노트 배경, false = 빈 캔버스 + var lined: Bool = false + /// PKDrawing.dataRepresentation() + @Attribute(.externalStorage) + var drawingData: Data = Data() + + var entry: DiaryEntry? + + @Relationship(deleteRule: .cascade, inverse: \DiaryPageItem.page) + var itemsStorage: [DiaryPageItem]? = [] + + init(index: Int) { + self.index = index + } + + var items: [DiaryPageItem] { + get { (itemsStorage ?? []).sorted { $0.createdAt < $1.createdAt } } + set { itemsStorage = newValue } + } + + var hasContent: Bool { + !drawingData.isEmpty || !(itemsStorage ?? []).isEmpty + } +} + +/// 노트 페이지 위에 배치하는 요소 (사진 또는 도형) +@Model +final class DiaryPageItem { + var uuid: UUID = UUID() + /// DiaryItemKind rawValue + var kindRaw: String = DiaryItemKind.photo.rawValue + /// 사진일 때만 사용 + @Attribute(.externalStorage) + var imageData: Data? = nil + /// 도형 색 (hex) + var colorHex: String = "#2F6B4F" + /// 페이지 폭 대비 중심 좌표 비율 (0...1) — 페이지 크기가 달라져도 같은 자리에 놓인다 + var centerX: Double = 0.5 + var centerY: Double = 0.3 + /// 페이지 폭 대비 너비 비율 + var widthRatio: Double = 0.35 + var rotationDegrees: Double = 0 + var createdAt: Date = Date() + + var page: DiaryPage? + + init(kind: DiaryItemKind) { + self.kindRaw = kind.rawValue + } + + var kind: DiaryItemKind { + get { DiaryItemKind(rawValue: kindRaw) ?? .photo } + set { kindRaw = newValue.rawValue } + } +} + +nonisolated enum DiaryItemKind: String, CaseIterable { + case photo + case rectangle + case ellipse + case arrow + case line + + /// 사진 대비 도형의 기본 세로 비율 (widthRatio 기준) + var defaultAspect: Double { + switch self { + case .photo: return 1 + case .rectangle: return 0.7 + case .ellipse: return 0.7 + case .arrow: return 0.35 + case .line: return 0.1 + } + } +} diff --git a/myApp/HaruDanim/Shared/Premium.swift b/myApp/HaruDanim/Shared/Premium.swift index aa62954..bf6e584 100644 --- a/myApp/HaruDanim/Shared/Premium.swift +++ b/myApp/HaruDanim/Shared/Premium.swift @@ -47,6 +47,8 @@ nonisolated enum PremiumFeature { case siriShortcuts /// iCloud 기기 간 동기화 case cloudSync + /// iPad 전용 일기(다이어리) 탭 + case diary } // MARK: - 권한 제공자 (StoreKit 주입 지점)