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
This commit is contained in:
parent
5c3017aeaa
commit
40d917d4e2
@ -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 {
|
||||
|
||||
@ -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": {
|
||||
|
||||
411
myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift
Normal file
411
myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift
Normal file
@ -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<DateComponents> = []
|
||||
@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<Date>
|
||||
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)
|
||||
}
|
||||
}
|
||||
407
myApp/HaruDanim/IOS/Views/DiaryNotePage.swift
Normal file
407
myApp/HaruDanim/IOS/Views/DiaryNotePage.swift
Normal file
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
789
myApp/HaruDanim/IOS/Views/DiaryView.swift
Normal file
789
myApp/HaruDanim/IOS/Views/DiaryView.swift
Normal file
@ -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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: ",")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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에 저장)
|
||||
|
||||
150
myApp/HaruDanim/Shared/DiaryModels.swift
Normal file
150
myApp/HaruDanim/Shared/DiaryModels.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -47,6 +47,8 @@ nonisolated enum PremiumFeature {
|
||||
case siriShortcuts
|
||||
/// iCloud 기기 간 동기화
|
||||
case cloudSync
|
||||
/// iPad 전용 일기(다이어리) 탭
|
||||
case diary
|
||||
}
|
||||
|
||||
// MARK: - 권한 제공자 (StoreKit 주입 지점)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user