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:
songyc macbook 2026-07-13 02:51:30 +09:00
parent 5c3017aeaa
commit 40d917d4e2
11 changed files with 2448 additions and 36 deletions

View File

@ -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 {

View File

@ -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": {

View 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)
}
}

View 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())
}
}
}

View 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)
}
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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: ",")
}
}

View File

@ -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 )

View 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
}
}
}

View File

@ -47,6 +47,8 @@ nonisolated enum PremiumFeature {
case siriShortcuts
/// iCloud
case cloudSync
/// iPad ()
case diary
}
// MARK: - (StoreKit )