- 첫 페이지를 '하루 정리' 화면으로 개편: 기록 유무와 무관한 24시간 고정 타임테이블(trimHours 옵션), 그 날짜에 진행 중이던 목표들의 다짐별 진행 현황 카드(하루 다짐은 그날 실적, 주간/월간/기간 다짐은 그 날짜까지 누적, '이하 유지'는 한도 게이지로 구분), 기록 상세·통계 막대 재배치 - 노트 페이지: UIScrollView 기반 핀치줌(두 손가락, fit~4배, 배치 모드에선 잠금) - 줄 노트 줄 간격 설정(좁게/보통/넓게/아주 넓게, 페이지별 저장·내보내기 반영) - 새 문자열 en/ja 번역 추가 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
1038 lines
38 KiB
Swift
1038 lines
38 KiB
Swift
//
|
|
// DiaryView.swift
|
|
// Haru_Danim
|
|
//
|
|
// 일기 탭 (iPad 전용, 프리미엄 — CLAUDE.md §7.2 확장 기능).
|
|
// 구조:
|
|
// - DiaryRootView: 월 달력. 일기를 쓴 날짜에 마커(기분 이모지 또는 초록 점)가 붙고,
|
|
// 날짜를 누르면 그 날짜의 일기로 이동. 툴바에서 기간/복수 날짜 내보내기.
|
|
// - DiaryDetailView: 좌우로 넘기는 페이지 구성. 첫 페이지 = 요약(오늘 날짜·기분·할 일·
|
|
// 타임테이블·통계), 이후 페이지 = 자유 필기 노트(DiaryNotePage.swift), 마지막 = 페이지 추가.
|
|
// - DiarySummaryPage: 그날의 기록/통계는 이미지 내보내기와 같은 스냅숏(ExportBuilder)을
|
|
// 재사용해 화면과 내보내기 결과가 항상 일치한다.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import PhotosUI
|
|
|
|
// MARK: - 루트 (달력)
|
|
|
|
struct DiaryRootView: View {
|
|
@Environment(\.modelContext) private var context
|
|
@Query private var diaryEntries: [DiaryEntry]
|
|
|
|
@State private var premium = PremiumManager.shared
|
|
/// 표시 중인 달의 아무 날짜 키 (그 달의 1일로 정규화해 사용)
|
|
@State private var monthAnchor: Date = DayMath().dayKey(for: .now)
|
|
@State private var showingExport = false
|
|
|
|
private var math: DayMath { DayMath() }
|
|
|
|
var body: some View {
|
|
Group {
|
|
if !premium.isPremium {
|
|
DiaryLockedView()
|
|
} else {
|
|
#if DEBUG
|
|
// 검증용: -diaryOpenToday YES → 달력 없이 오늘 일기를 바로 렌더
|
|
if UserDefaults.standard.bool(forKey: "diaryOpenToday") {
|
|
DiaryDetailView(dayKey: math.dayKey(for: .now))
|
|
} else {
|
|
calendar
|
|
}
|
|
#else
|
|
calendar
|
|
#endif
|
|
}
|
|
}
|
|
.background(AppTheme.background)
|
|
.navigationTitle("일기")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.onAppear {
|
|
#if DEBUG
|
|
// 검증용: -diarySeed YES → 오늘 일기(기분·할 일·노트 페이지) 데모 생성
|
|
if UserDefaults.standard.bool(forKey: "diarySeed") {
|
|
seedToday()
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private var calendar: some View {
|
|
ScrollView {
|
|
VStack(spacing: 14) {
|
|
monthHeader
|
|
DiaryCalendarGrid(
|
|
monthAnchor: monthAnchor,
|
|
entriesByDay: entriesByDay,
|
|
math: math
|
|
)
|
|
monthSummary
|
|
}
|
|
.padding(20)
|
|
.frame(maxWidth: 760)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.navigationDestination(for: Date.self) { key in
|
|
DiaryDetailView(dayKey: key)
|
|
}
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
showingExport = true
|
|
} label: {
|
|
Image(systemName: "square.and.arrow.up")
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingExport) {
|
|
DiaryExportSheet()
|
|
}
|
|
.onAppear {
|
|
#if DEBUG
|
|
if UserDefaults.standard.bool(forKey: "diaryShowExport") {
|
|
showingExport = true
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// 작성된 일기(내용 있는 것만)를 하루 키로 색인
|
|
private var entriesByDay: [Date: DiaryEntry] {
|
|
var result: [Date: DiaryEntry] = [:]
|
|
for entry in diaryEntries where entry.hasContent {
|
|
result[entry.dayKey] = entry
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: 월 이동 헤더
|
|
|
|
private var monthHeader: some View {
|
|
HStack {
|
|
Button {
|
|
moveMonth(-1)
|
|
} label: {
|
|
Image(systemName: "chevron.left")
|
|
.frame(width: 44, height: 36)
|
|
.contentShape(Rectangle())
|
|
}
|
|
Spacer()
|
|
Text(monthAnchor.formatted(.dateTime.year().month()))
|
|
.font(.title3.weight(.bold))
|
|
Spacer()
|
|
Button {
|
|
moveMonth(1)
|
|
} label: {
|
|
Image(systemName: "chevron.right")
|
|
.frame(width: 44, height: 36)
|
|
.contentShape(Rectangle())
|
|
}
|
|
Button("이번 달") {
|
|
monthAnchor = math.dayKey(for: .now)
|
|
}
|
|
.font(.caption)
|
|
.buttonStyle(.bordered)
|
|
.buttonBorderShape(.capsule)
|
|
}
|
|
.tint(AppTheme.green)
|
|
}
|
|
|
|
private func moveMonth(_ delta: Int) {
|
|
monthAnchor = math.calendar.date(byAdding: .month, value: delta, to: monthAnchor)!
|
|
}
|
|
|
|
private var monthSummary: some View {
|
|
let range = math.monthRange(containing: monthAnchor)
|
|
let count = entriesByDay.keys.filter { range.contains(math.dayRange(forKey: $0).lowerBound) }.count
|
|
return HStack(spacing: 6) {
|
|
Image(systemName: "pencil.and.scribble")
|
|
.font(.caption)
|
|
Text("이번 달에 쓴 일기 \(count)편")
|
|
.font(.caption.weight(.medium))
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
#if DEBUG
|
|
private func seedToday() {
|
|
let key = math.dayKey(for: .now)
|
|
let existing = diaryEntries.first { $0.dayKey == key }
|
|
guard existing == nil else { return }
|
|
let entry = DiaryEntry(dayKey: key)
|
|
entry.moodEmoji = "🙂"
|
|
context.insert(entry)
|
|
let todoTexts = ["물 2리터 마시기", "독서 30분", "저녁 달리기"]
|
|
for (index, text) in todoTexts.enumerated() {
|
|
let todo = DiaryTodo(text: text, sortOrder: index)
|
|
todo.isDone = index == 0
|
|
todo.entry = entry
|
|
context.insert(todo)
|
|
}
|
|
let page = DiaryPage(index: 0)
|
|
page.lined = true
|
|
page.entry = entry
|
|
context.insert(page)
|
|
let rect = DiaryPageItem(kind: .rectangle)
|
|
rect.centerX = 0.3; rect.centerY = 0.25; rect.widthRatio = 0.35
|
|
rect.page = page
|
|
context.insert(rect)
|
|
let arrow = DiaryPageItem(kind: .arrow)
|
|
arrow.colorHex = "#D9A621"
|
|
arrow.centerX = 0.62; arrow.centerY = 0.4; arrow.widthRatio = 0.3
|
|
arrow.page = page
|
|
context.insert(arrow)
|
|
try? context.save()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - 프리미엄 잠금 안내
|
|
|
|
struct DiaryLockedView: View {
|
|
var body: some View {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "crown.fill")
|
|
.font(.system(size: 40))
|
|
.foregroundStyle(AppTheme.yellow)
|
|
Text("일기는 프리미엄 기능이에요")
|
|
.font(.title3.weight(.semibold))
|
|
Text("하루의 기록·통계와 함께 애플 펜슬로 쓰는\n나만의 다이어리를 만들 수 있어요.\n설정 → 프리미엄에서 잠금 해제해 주세요.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
// MARK: - 달력 그리드
|
|
|
|
private struct DiaryCalendarGrid: View {
|
|
let monthAnchor: Date
|
|
let entriesByDay: [Date: DiaryEntry]
|
|
let math: DayMath
|
|
|
|
private var calendar: Calendar { math.calendar }
|
|
|
|
/// 주 시작 요일 설정을 따르는 요일 헤더 순서
|
|
private var weekdayOrder: [Int] {
|
|
(0..<7).map { (calendar.firstWeekday - 1 + $0) % 7 + 1 }
|
|
}
|
|
|
|
/// 이 달 그리드 셀 (앞쪽 빈칸은 nil)
|
|
private var cells: [Date?] {
|
|
let range = math.monthRange(containing: monthAnchor)
|
|
let keys = math.dayKeys(in: range)
|
|
guard let first = keys.first else { return [] }
|
|
let firstWeekday = calendar.component(.weekday, from: first)
|
|
let leading = (firstWeekday - calendar.firstWeekday + 7) % 7
|
|
return Array(repeating: nil, count: leading) + keys.map { Optional($0) }
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 6) {
|
|
HStack(spacing: 0) {
|
|
ForEach(weekdayOrder, id: \.self) { weekday in
|
|
Text(Format.weekdayShort(weekday))
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(weekday == 1 ? Color.red.opacity(0.7) : .secondary)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7), spacing: 4) {
|
|
ForEach(Array(cells.enumerated()), id: \.offset) { _, key in
|
|
if let key {
|
|
NavigationLink(value: key) {
|
|
dayCell(key)
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
Color.clear
|
|
.frame(height: 74)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(14)
|
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
|
}
|
|
|
|
private func dayCell(_ key: Date) -> some View {
|
|
let isToday = key == math.dayKey(for: .now)
|
|
let entry = entriesByDay[key]
|
|
return VStack(spacing: 3) {
|
|
Text("\(calendar.component(.day, from: key))")
|
|
.font(.callout.weight(isToday ? .bold : .regular))
|
|
.monospacedDigit()
|
|
.foregroundStyle(isToday ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
|
|
.frame(width: 32, height: 32)
|
|
.background(
|
|
Circle().fill(isToday ? AppTheme.green : Color.clear)
|
|
)
|
|
// 작성 마커: 기분 이모지가 있으면 이모지, 아니면 연필 점
|
|
Group {
|
|
if let entry {
|
|
if entry.moodEmoji.isEmpty {
|
|
Image(systemName: "applepencil.tip")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(AppTheme.green)
|
|
} else {
|
|
Text(entry.moodEmoji)
|
|
.font(.system(size: 15))
|
|
}
|
|
} else {
|
|
Color.clear
|
|
}
|
|
}
|
|
.frame(height: 20)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 74)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
.fill(entry != nil ? AppTheme.green.opacity(0.07) : Color.clear)
|
|
)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
// MARK: - 일기 상세 (페이지 넘김)
|
|
|
|
struct DiaryDetailView: View {
|
|
let dayKey: Date
|
|
|
|
@Environment(\.modelContext) private var context
|
|
@AppStorage("diary.showMood", store: AppGroup.defaults) private var showMood = true
|
|
|
|
@State private var entry: DiaryEntry?
|
|
@State private var pageIndex: Int = {
|
|
#if DEBUG
|
|
// 검증용: -diaryPage N → 해당 페이지로 시작 (0=요약)
|
|
if let raw = UserDefaults.standard.string(forKey: "diaryPage"), let index = Int(raw) {
|
|
return index
|
|
}
|
|
#endif
|
|
return 0
|
|
}()
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let entry {
|
|
pager(entry)
|
|
} else {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
.background(AppTheme.background)
|
|
.navigationTitle(Format.fullDate(dayKey))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Menu {
|
|
Toggle("‘오늘 기분’ 영역 표시", isOn: $showMood)
|
|
Button {
|
|
addPage()
|
|
} label: {
|
|
Label("페이지 추가", systemImage: "plus.square.on.square")
|
|
}
|
|
if let entry, pageIndex >= 1, pageIndex <= entry.pages.count {
|
|
Button(role: .destructive) {
|
|
deleteCurrentPage()
|
|
} label: {
|
|
Label("이 페이지 삭제", systemImage: "trash")
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
}
|
|
}
|
|
.onAppear(perform: loadEntry)
|
|
}
|
|
|
|
private func pager(_ entry: DiaryEntry) -> some View {
|
|
TabView(selection: $pageIndex) {
|
|
DiarySummaryPage(entry: entry, showMood: showMood)
|
|
.tag(0)
|
|
ForEach(Array(entry.pages.enumerated()), id: \.element.uuid) { index, page in
|
|
DiaryNotePageView(page: page, isActive: pageIndex == index + 1)
|
|
.tag(index + 1)
|
|
}
|
|
addPagePlaceholder
|
|
.tag(entry.pages.count + 1)
|
|
}
|
|
.tabViewStyle(.page(indexDisplayMode: .always))
|
|
.indexViewStyle(.page(backgroundDisplayMode: .always))
|
|
}
|
|
|
|
/// 마지막 페이지: 누르면 새 노트 페이지가 그 자리에 생긴다 (빈 페이지가 계속 이어지는 노트)
|
|
private var addPagePlaceholder: some View {
|
|
Button {
|
|
addPage()
|
|
} label: {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "plus")
|
|
.font(.system(size: 34, weight: .semibold))
|
|
Text("새 페이지")
|
|
.font(.headline)
|
|
Text("애플 펜슬로 자유롭게 쓰고 그려 보세요")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.foregroundStyle(AppTheme.green)
|
|
.frame(maxWidth: 520, maxHeight: 680)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [8, 6]))
|
|
.foregroundStyle(AppTheme.green.opacity(0.5))
|
|
)
|
|
.padding(40)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func loadEntry() {
|
|
guard entry == nil else { return }
|
|
let key = dayKey
|
|
let descriptor = FetchDescriptor<DiaryEntry>(predicate: #Predicate { $0.dayKey == key })
|
|
if let found = try? context.fetch(descriptor).first {
|
|
entry = found
|
|
} else {
|
|
let created = DiaryEntry(dayKey: key)
|
|
context.insert(created)
|
|
try? context.save()
|
|
entry = created
|
|
}
|
|
}
|
|
|
|
private func addPage() {
|
|
guard let entry else { return }
|
|
let page = DiaryPage(index: entry.pages.count)
|
|
page.entry = entry
|
|
context.insert(page)
|
|
entry.updatedAt = .now
|
|
try? context.save()
|
|
pageIndex = entry.pages.count // 새 페이지로 이동
|
|
}
|
|
|
|
private func deleteCurrentPage() {
|
|
guard let entry, pageIndex >= 1, pageIndex <= entry.pages.count else { return }
|
|
let page = entry.pages[pageIndex - 1]
|
|
context.delete(page)
|
|
// 순서 재정렬
|
|
for (index, remaining) in entry.pages.filter({ $0 !== page }).enumerated() {
|
|
remaining.index = index
|
|
}
|
|
entry.updatedAt = .now
|
|
try? context.save()
|
|
pageIndex = min(pageIndex, entry.pages.count)
|
|
}
|
|
}
|
|
|
|
// MARK: - 첫 페이지 (하루 정리)
|
|
|
|
/// "오늘 하루를 정리한 모습"이 목표인 첫 페이지.
|
|
/// - 타임테이블은 기록 유무와 무관하게 항상 24시간 전체를 보여준다 (trimHours: false)
|
|
/// - 그 날짜에 진행 중이던 목표들을 찾아 다짐별 진행 현황을 보여준다.
|
|
/// 주간/월간 다짐은 "그 날짜까지의 누적"으로 계산해 과거 일기에서도 그날 기준 수치가 된다.
|
|
private struct DiarySummaryPage: View {
|
|
@Bindable var entry: DiaryEntry
|
|
let showMood: Bool
|
|
|
|
@Environment(\.modelContext) private var context
|
|
@Query private var sessions: [TimeSession]
|
|
@Query private var countEntries: [CountEntry]
|
|
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
|
|
@Query(sort: \Goal.sortOrder) private var allGoals: [Goal]
|
|
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
|
|
|
|
private var math: DayMath { DayMath() }
|
|
|
|
private var orderedActions: [Action] {
|
|
LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw)
|
|
}
|
|
|
|
/// 진행률 계산 기준 시각: 오늘 일기면 지금, 과거/미래 일기면 그 날짜의 끝
|
|
private var referenceNow: Date {
|
|
let range = math.dayRange(forKey: entry.dayKey)
|
|
return range.contains(.now) ? .now : range.upperBound.addingTimeInterval(-1)
|
|
}
|
|
|
|
/// 이 날짜에 진행 중이던 목표 (시작일 도래 & 종료일 이전)
|
|
private var activeGoals: [Goal] {
|
|
let day = math.dayRange(forKey: entry.dayKey).lowerBound
|
|
let cal = math.calendar
|
|
return allGoals.filter { goal in
|
|
guard cal.startOfDay(for: goal.startDate) <= day else { return false }
|
|
if let end = goal.endDate {
|
|
return cal.startOfDay(for: end) >= day
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// 그날의 기록 스냅숏 (이미지 내보내기와 동일한 데이터 — 24시간 타임테이블 + 기록 목록)
|
|
private var daySnapshot: ExportSnapshot {
|
|
ExportBuilder.history(
|
|
dayKey: entry.dayKey,
|
|
weeklyTimetable: false,
|
|
sessions: sessions,
|
|
entries: countEntries,
|
|
orderedActions: orderedActions,
|
|
excludedActionIDs: [],
|
|
math: math,
|
|
trimHours: false
|
|
)
|
|
}
|
|
|
|
/// 꼬리표별 시간/횟수 막대 (하루 통계에서 발췌)
|
|
private var statBars: [ExportSectionData] {
|
|
ExportBuilder.stats(
|
|
span: .day,
|
|
anchorDayKey: entry.dayKey,
|
|
sessions: sessions,
|
|
entries: countEntries,
|
|
orderedActions: orderedActions,
|
|
excludedActionIDs: [],
|
|
math: math
|
|
).sections.filter {
|
|
if case .bars = $0 { return true }
|
|
return false
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
let wide = geo.size.width >= 720
|
|
let timetableWidth: CGFloat = 300
|
|
let rightWidth = geo.size.width - 40 - timetableWidth - 16
|
|
let fullWidth = geo.size.width - 40
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
header
|
|
if showMood {
|
|
DiaryMoodCard(entry: entry)
|
|
}
|
|
ExportHeroRow(stats: daySnapshot.hero)
|
|
if wide {
|
|
HStack(alignment: .top, spacing: 16) {
|
|
// 왼쪽: 24시간 타임테이블 (세로로 길어 한 컬럼을 전담)
|
|
VStack(spacing: 14) {
|
|
timetableSections(innerWidth: timetableWidth - 28)
|
|
}
|
|
.frame(width: timetableWidth)
|
|
// 오른쪽: 목표·다짐 현황 → 할 일 → 기록 상세 → 통계 막대
|
|
VStack(spacing: 14) {
|
|
DiaryGoalsCard(
|
|
goals: activeGoals,
|
|
dayKey: entry.dayKey,
|
|
referenceNow: referenceNow,
|
|
math: math
|
|
)
|
|
DiaryTodoCard(entry: entry)
|
|
recordSections(innerWidth: rightWidth - 28)
|
|
barSections(innerWidth: rightWidth - 28)
|
|
}
|
|
}
|
|
} else {
|
|
DiaryGoalsCard(
|
|
goals: activeGoals,
|
|
dayKey: entry.dayKey,
|
|
referenceNow: referenceNow,
|
|
math: math
|
|
)
|
|
DiaryTodoCard(entry: entry)
|
|
timetableSections(innerWidth: fullWidth - 28)
|
|
recordSections(innerWidth: fullWidth - 28)
|
|
barSections(innerWidth: fullWidth - 28)
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
|
Text(Format.fullDate(entry.dayKey))
|
|
.font(.system(size: 28, weight: .bold))
|
|
if entry.dayKey == math.dayKey(for: .now) {
|
|
Text("오늘")
|
|
.font(.caption.weight(.bold))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(AppTheme.green, in: Capsule())
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func timetableSections(innerWidth: CGFloat) -> some View {
|
|
ForEach(Array(daySnapshot.sections.enumerated()), id: \.offset) { _, section in
|
|
if case .timetable = section {
|
|
ExportSectionView(section: section, innerWidth: innerWidth)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func recordSections(innerWidth: CGFloat) -> some View {
|
|
let recordSections = daySnapshot.sections.filter {
|
|
if case .records = $0 { return true }
|
|
return false
|
|
}
|
|
if recordSections.isEmpty {
|
|
Text("이 날의 기록이 없어요")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 30)
|
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
} else {
|
|
ForEach(Array(recordSections.enumerated()), id: \.offset) { _, section in
|
|
ExportSectionView(section: section, innerWidth: innerWidth)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func barSections(innerWidth: CGFloat) -> some View {
|
|
ForEach(Array(statBars.enumerated()), id: \.offset) { _, section in
|
|
ExportSectionView(section: section, innerWidth: innerWidth)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 이 날의 목표·다짐 현황 카드
|
|
|
|
/// 그 날짜에 진행 중이던 목표들의 다짐별 진행 현황.
|
|
/// - 하루 다짐: 그 날짜가 수행일이면 그날의 실적/목표, 아니면 "수행일 아님"으로 표시
|
|
/// - 주간/월간/기간 다짐: 주기 시작부터 "그 날짜 끝"까지의 누적을 목표량과 비교
|
|
/// - '이하 유지' 다짐은 한도 대비 사용량 게이지(노랑, 초과 시 빨강)로 달성형과 구분
|
|
private struct DiaryGoalsCard: View {
|
|
let goals: [Goal]
|
|
let dayKey: Date
|
|
let referenceNow: Date
|
|
let math: DayMath
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Text("이 날의 목표")
|
|
.font(.subheadline.weight(.semibold))
|
|
Spacer()
|
|
Text(String(localized: "\(goals.count)개 진행 중"))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if goals.isEmpty {
|
|
Text("이 날짜에 진행 중인 목표가 없어요")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
} else {
|
|
ForEach(Array(goals.enumerated()), id: \.element.uuid) { index, goal in
|
|
if index > 0 {
|
|
Divider()
|
|
}
|
|
goalBlock(goal)
|
|
}
|
|
}
|
|
}
|
|
.padding(14)
|
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
}
|
|
|
|
// MARK: 목표 한 덩어리 (헤더 + 다짐 줄들)
|
|
|
|
@ViewBuilder
|
|
private func goalBlock(_ goal: Goal) -> some View {
|
|
let dayRatio = goal.combinedSpanRatio(.day, math: math, now: referenceNow)
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: goal.symbolName)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 28, height: 28)
|
|
.background(goal.color, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
Text(goal.title)
|
|
.font(.footnote.weight(.semibold))
|
|
.lineLimit(1)
|
|
Spacer(minLength: 6)
|
|
Text("\(Int((dayRatio * 100).rounded()))%")
|
|
.font(.footnote.weight(.bold).monospacedDigit())
|
|
.foregroundStyle(goal.color)
|
|
}
|
|
// 목표 단위 하루 진행률 (다짐 진행률 평균)
|
|
gaugeBar(ratio: dayRatio, color: goal.color)
|
|
if goal.sortedQuests.isEmpty {
|
|
Text("다짐이 아직 없는 목표예요")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
} else {
|
|
ForEach(goal.sortedQuests, id: \.uuid) { quest in
|
|
questRow(quest)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func gaugeBar(ratio: Double, color: Color, height: CGFloat = 6) -> some View {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule().fill(color.opacity(0.15))
|
|
Capsule()
|
|
.fill(color)
|
|
.frame(width: max(geo.size.width * min(max(ratio, 0), 1), ratio > 0 ? height : 0))
|
|
}
|
|
}
|
|
.frame(height: height)
|
|
}
|
|
|
|
// MARK: 다짐 한 줄
|
|
|
|
private struct QuestStat {
|
|
var contextLabel: String
|
|
var value: Double?
|
|
var achieved: Bool
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func questRow(_ quest: Quest) -> some View {
|
|
let stat = questStat(quest)
|
|
let target = quest.targetValue
|
|
let limit = quest.direction == .atMost
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Image(systemName: quest.targetSymbol)
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 20, height: 20)
|
|
.background(quest.targetColor, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Text(quest.targetName)
|
|
.font(.caption.weight(.medium))
|
|
.lineLimit(1)
|
|
Text(stat.contextLabel)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
Spacer(minLength: 4)
|
|
if let value = stat.value {
|
|
Group {
|
|
if limit {
|
|
Text("\(valueLabel(value, quest: quest)) / 한도 \(quest.targetValueLabel)")
|
|
} else {
|
|
Text("\(valueLabel(value, quest: quest)) / \(quest.targetValueLabel)")
|
|
}
|
|
}
|
|
.font(.caption2.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
if stat.achieved {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(AppTheme.green)
|
|
} else if limit {
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
if let value = stat.value, target > 0 {
|
|
let ratio = value / target
|
|
gaugeBar(
|
|
ratio: ratio,
|
|
color: limit ? (ratio > 1 ? .red : AppTheme.yellow) : quest.targetColor,
|
|
height: 4
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.opacity(stat.value == nil ? 0.55 : 1)
|
|
}
|
|
|
|
/// 다짐의 "그 날짜 기준" 실적. value == nil이면 이 날짜엔 해당 없음(수행일 아님/기간 밖).
|
|
private func questStat(_ quest: Quest) -> QuestStat {
|
|
let progress = QuestProgress(quest: quest, math: math)
|
|
let dayRange = math.dayRange(forKey: dayKey)
|
|
|
|
let measured: Range<Date>?
|
|
let context: String
|
|
switch quest.period {
|
|
case .daily:
|
|
if progress.isActiveDay(dayKey) {
|
|
measured = dayRange
|
|
context = String(localized: "하루 목표")
|
|
} else {
|
|
measured = nil
|
|
context = String(localized: "이 날은 수행일이 아니에요")
|
|
}
|
|
case .weekly:
|
|
let week = math.weekRange(containing: dayRange.lowerBound)
|
|
measured = week.lowerBound..<min(dayRange.upperBound, week.upperBound)
|
|
context = String(localized: "주간 목표 · 이 날까지 누적")
|
|
case .monthly:
|
|
let month = math.monthRange(containing: dayRange.lowerBound)
|
|
measured = month.lowerBound..<min(dayRange.upperBound, month.upperBound)
|
|
context = String(localized: "월간 목표 · 이 날까지 누적")
|
|
case .custom:
|
|
if let full = progress.currentPeriodRange(now: referenceNow), full.overlaps(dayRange) {
|
|
measured = full.lowerBound..<min(dayRange.upperBound, full.upperBound)
|
|
context = String(localized: "기간 목표 · 이 날까지 누적")
|
|
} else {
|
|
measured = nil
|
|
context = String(localized: "설정된 기간 밖이에요")
|
|
}
|
|
}
|
|
|
|
guard let measured else {
|
|
return QuestStat(contextLabel: context, value: nil, achieved: false)
|
|
}
|
|
let value = progress.value(in: measured, now: referenceNow)
|
|
let achieved: Bool
|
|
switch quest.direction {
|
|
case .atLeast: achieved = value >= quest.targetValue
|
|
case .atMost: achieved = value <= quest.targetValue
|
|
}
|
|
return QuestStat(contextLabel: context, value: value, achieved: achieved)
|
|
}
|
|
|
|
private func valueLabel(_ value: Double, quest: Quest) -> String {
|
|
switch quest.measure {
|
|
case .time: return Format.durationShort(value)
|
|
case .count: return String(localized: "\(Int(value))회")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 오늘 기분 카드
|
|
|
|
private struct DiaryMoodCard: View {
|
|
@Bindable var entry: DiaryEntry
|
|
@Environment(\.modelContext) private var context
|
|
|
|
@State private var showingEmojiPicker = false
|
|
@State private var photoSelection: PhotosPickerItem?
|
|
|
|
static let emojiChoices = [
|
|
"😀", "🙂", "😌", "🥰", "🥳", "🤗", "😴", "😐",
|
|
"😔", "😢", "😡", "😤", "🤒", "🤯", "😱", "🙃",
|
|
]
|
|
|
|
var body: some View {
|
|
HStack(spacing: 14) {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("오늘 기분")
|
|
.font(.subheadline.weight(.semibold))
|
|
Text("이모지나 사진으로 오늘의 기분을 남겨 보세요")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
|
|
// 이모지 선택
|
|
Button {
|
|
showingEmojiPicker = true
|
|
} label: {
|
|
Group {
|
|
if entry.moodEmoji.isEmpty {
|
|
Image(systemName: "face.smiling")
|
|
.font(.system(size: 26))
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text(entry.moodEmoji)
|
|
.font(.system(size: 34))
|
|
}
|
|
}
|
|
.frame(width: 60, height: 60)
|
|
.background(AppTheme.background, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.popover(isPresented: $showingEmojiPicker) {
|
|
emojiPicker
|
|
}
|
|
|
|
// 사진 선택
|
|
PhotosPicker(selection: $photoSelection, matching: .images) {
|
|
Group {
|
|
if let data = entry.moodImageData, let image = UIImage(data: data) {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.scaledToFill()
|
|
} else {
|
|
Image(systemName: "photo.badge.plus")
|
|
.font(.system(size: 24))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.frame(width: 60, height: 60)
|
|
.background(AppTheme.background)
|
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if !entry.moodEmoji.isEmpty || entry.moodImageData != nil {
|
|
Button {
|
|
entry.moodEmoji = ""
|
|
entry.moodImageData = nil
|
|
touch()
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.title3)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(14)
|
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
.onChange(of: photoSelection) {
|
|
guard let photoSelection else { return }
|
|
Task {
|
|
if let data = try? await photoSelection.loadTransferable(type: Data.self),
|
|
let image = UIImage(data: data) {
|
|
// 저장 용량을 위해 리사이즈 + JPEG 압축
|
|
entry.moodImageData = image.diaryCompressed()
|
|
touch()
|
|
}
|
|
self.photoSelection = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private var emojiPicker: some View {
|
|
LazyVGrid(columns: Array(repeating: GridItem(.fixed(46)), count: 4), spacing: 8) {
|
|
ForEach(Self.emojiChoices, id: \.self) { emoji in
|
|
Button {
|
|
entry.moodEmoji = emoji
|
|
touch()
|
|
showingEmojiPicker = false
|
|
} label: {
|
|
Text(emoji)
|
|
.font(.system(size: 30))
|
|
.frame(width: 46, height: 46)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.presentationCompactAdaptation(.popover)
|
|
}
|
|
|
|
private func touch() {
|
|
entry.updatedAt = .now
|
|
try? context.save()
|
|
}
|
|
}
|
|
|
|
extension UIImage {
|
|
/// 기분 사진 저장용 축소 (최대 변 900pt, JPEG 0.8)
|
|
func diaryCompressed() -> Data? {
|
|
let maxSide: CGFloat = 900
|
|
let longest = max(size.width, size.height)
|
|
let scale = longest > maxSide ? maxSide / longest : 1
|
|
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
|
let renderer = UIGraphicsImageRenderer(size: newSize)
|
|
let resized = renderer.image { _ in
|
|
draw(in: CGRect(origin: .zero, size: newSize))
|
|
}
|
|
return resized.jpegData(compressionQuality: 0.8)
|
|
}
|
|
}
|
|
|
|
// MARK: - 오늘 할 일 카드 (펜슬 스크리블 입력 + 탭 체크)
|
|
|
|
private struct DiaryTodoCard: View {
|
|
@Bindable var entry: DiaryEntry
|
|
@Environment(\.modelContext) private var context
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Text("오늘 할 일")
|
|
.font(.subheadline.weight(.semibold))
|
|
Spacer()
|
|
Button {
|
|
addTodo()
|
|
} label: {
|
|
Image(systemName: "plus.circle.fill")
|
|
.font(.title3)
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
if entry.todos.isEmpty {
|
|
Text("펜슬이나 키보드로 할 일을 적고,\n체크 표시로 완료해 보세요")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
} else {
|
|
ForEach(entry.todos, id: \.uuid) { todo in
|
|
DiaryTodoRow(todo: todo, onDelete: { delete(todo) })
|
|
}
|
|
}
|
|
}
|
|
.padding(14)
|
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
}
|
|
|
|
private func addTodo() {
|
|
let todo = DiaryTodo(text: "", sortOrder: (entry.todos.last?.sortOrder ?? -1) + 1)
|
|
todo.entry = entry
|
|
context.insert(todo)
|
|
entry.updatedAt = .now
|
|
try? context.save()
|
|
}
|
|
|
|
private func delete(_ todo: DiaryTodo) {
|
|
context.delete(todo)
|
|
entry.updatedAt = .now
|
|
try? context.save()
|
|
}
|
|
}
|
|
|
|
private struct DiaryTodoRow: View {
|
|
@Bindable var todo: DiaryTodo
|
|
let onDelete: () -> Void
|
|
|
|
@Environment(\.modelContext) private var context
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
Button {
|
|
todo.isDone.toggle()
|
|
try? context.save()
|
|
} label: {
|
|
Image(systemName: todo.isDone ? "checkmark.circle.fill" : "circle")
|
|
.font(.title3)
|
|
.foregroundStyle(todo.isDone ? AppTheme.green : .secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
TextField("할 일", text: $todo.text)
|
|
.font(.subheadline)
|
|
.strikethrough(todo.isDone, color: .secondary)
|
|
.foregroundStyle(todo.isDone ? .secondary : .primary)
|
|
.onSubmit { try? context.save() }
|
|
|
|
Button(action: onDelete) {
|
|
Image(systemName: "xmark")
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
.padding(4)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|