하루 단위 다짐에 '마감 시각까지만 집계' 옵션 추가 (최소판 설계): - 모델: Quest.deadlineMinutes(-1=없음) 필드 1개 — CloudKit 안전(기본값), 주기가 하루 단위가 아니면 값이 남아도 무시(hasDeadline이 주기·범위를 함께 검사) - 의미론: 마감 = 그날 집계 창의 끝. dayMeasurementRange(forKey:) 단일 구현을 주기 범위(current/judgment)·주/월 span 하루 루프(spanValue)·연속 버킷팅 (perDayValues)·위젯 누적 라벨(spanRawValue)·일기 카드가 공유한다. 원본 기록은 불변 — 마감 뒤 기록은 남되 그 다짐에만 미집계. 시간형은 기존 겹침 클리핑이 마감을 걸친 세션을 부분 인정(추가 코드 0). '이하 유지'는 마감까지 한도 안이면 그날 달성으로 고정. - 경계 규칙: 마감은 그 논리적 하루(24h) 안에서 그 시계 시각의 등장 지점 — 하루 시작보다 이른 시각은 다음 달력일 새벽, 시작과 같은 시각은 +24h(온전한 하루)로 0길이 창이 수학적으로 불가능(에디터 검증 불필요, 오류 상태 없음) - 의도된 최소화: '마감 지남' 전용 표시 없음(게이지 값이 마감 시점에 연속이라 위젯 타임라인 마감 경계 불필요 — 위젯·워치·시리는 코드 변경 0으로 자동 일관), 연속 달성은 값 클리핑만(오늘 놓친 끊김 반영은 내일부터 — 유예 규칙 유지), 주간/월간/기간 마감은 미지원 - UI: 다짐 에디터 토글+시간 선택(하루 단위만, footer 설명), 주기 문구에 "· 오전 8:00까지" 표기(목표 탭·시리 요약·CSV 자동 반영), 일기 카드 "하루 목표 · 오전 8:00까지", 도움말 항목 추가, ko/en/ja 번역 - 검증: seedDemo에 마감 다짐 시드(창 안/밖/어제 실패 케이스) — 하루 100% (창 밖 기록 포함 시 200%가 되는 함정 통과), 주간 29%(=2/7)·월간 6%(=2/31) 수기 계산과 정확 일치, streak-dump 연속 1일(어제 실패로 끊김) 확인, 마감 없는 다짐 전부 기존 수치 유지(경로가 hasDeadline 가드로 완전 동일), 일기 카드 1회/1회(원본 합계는 그대로), 영어 로케일 "By 8:00 AM" 렌더, Debug·Store 빌드 성공, 카탈로그 9종 missing/stale 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
1905 lines
77 KiB
Swift
1905 lines
77 KiB
Swift
//
|
|
// DiaryView.swift
|
|
// Haru_Danim
|
|
//
|
|
// 일기 탭 (iPad 전용, 프리미엄 — CLAUDE.md §7.2 확장 기능).
|
|
// 구조:
|
|
// - DiaryRootView: 월 달력. 일기를 쓴 날짜에 마커(기분 이모지 또는 초록 점)가 붙고,
|
|
// 날짜를 누르면 그 날짜의 일기로 이동. 툴바에서 기간/복수 날짜 내보내기.
|
|
// - DiaryDetailView: 좌우로 넘기는 페이지 구성. 첫 페이지 = 요약(오늘 날짜·기분·할 일·
|
|
// 타임테이블·통계), 이후 페이지 = 자유 필기 노트(DiaryNotePage.swift), 마지막 = 페이지 추가.
|
|
// - DiarySummaryPage: 그날의 기록/통계는 이미지 내보내기와 같은 스냅숏(ExportBuilder)을
|
|
// 재사용해 화면과 내보내기 결과가 항상 일치한다.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import PhotosUI
|
|
#if DEBUG
|
|
import PencilKit // 시드 필기 획 생성용 (검증 전용)
|
|
#endif
|
|
|
|
// MARK: - 읽기 전용 모드 (프리미엄 만료 후 열람 허용)
|
|
|
|
/// 프리미엄이 끝나도 이미 쓴 일기는 볼 수 있어야 한다 — 이 값이 true면
|
|
/// 일기 화면 전체가 열람 전용이 된다 (새 엔트리 생성·편집 UI 비활성).
|
|
private struct DiaryReadOnlyKey: EnvironmentKey {
|
|
static let defaultValue = false
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var diaryReadOnly: Bool {
|
|
get { self[DiaryReadOnlyKey.self] }
|
|
set { self[DiaryReadOnlyKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
// 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
|
|
@State private var showingTemplates = false
|
|
@State private var showingPremiumSheet = false
|
|
|
|
private var math: DayMath { DayMath() }
|
|
|
|
/// 만료 후에도 이미 쓴 일기가 있으면 열람 전용으로 달력을 연다
|
|
private var isReadOnly: Bool {
|
|
!premium.isPremium && diaryEntries.contains { $0.hasContent }
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if !premium.isPremium && !isReadOnly {
|
|
DiaryLockedView()
|
|
} else {
|
|
#if DEBUG
|
|
// 검증용: -diaryOpenToday YES → 달력 없이 오늘 일기를 바로 렌더
|
|
if UserDefaults.standard.bool(forKey: "diaryOpenToday") {
|
|
DiaryDetailView(dayKey: math.dayKey(for: .now))
|
|
} else {
|
|
calendar
|
|
}
|
|
#else
|
|
calendar
|
|
#endif
|
|
}
|
|
}
|
|
.environment(\.diaryReadOnly, isReadOnly)
|
|
.background(AppTheme.background)
|
|
.navigationTitle("일기")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.onAppear {
|
|
#if DEBUG
|
|
// 검증용: -diarySeed YES → 오늘 일기(기분·할 일·노트 페이지) 데모 생성
|
|
if UserDefaults.standard.bool(forKey: "diarySeed") {
|
|
seedToday()
|
|
}
|
|
// 검증용: -diarySeedTemplate YES → 2쪽짜리 샘플 PDF 양식 생성 (파일 가져오기 없이 검증)
|
|
if UserDefaults.standard.bool(forKey: "diarySeedTemplate") {
|
|
seedSampleTemplate()
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private var calendar: some View {
|
|
ScrollView {
|
|
VStack(spacing: 14) {
|
|
if isReadOnly {
|
|
readOnlyBanner
|
|
}
|
|
monthHeader
|
|
DiaryCalendarGrid(
|
|
monthAnchor: monthAnchor,
|
|
entriesByDay: entriesByDay,
|
|
math: math,
|
|
readOnly: isReadOnly
|
|
)
|
|
monthSummary
|
|
}
|
|
.padding(20)
|
|
.frame(maxWidth: 760)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.navigationDestination(for: Date.self) { key in
|
|
DiaryDetailView(dayKey: key)
|
|
}
|
|
.toolbar {
|
|
// 양식은 새 페이지 작성용이므로 열람 전용(프리미엄 만료)에서는 관리 진입도 숨긴다
|
|
if !isReadOnly {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
showingTemplates = true
|
|
} label: {
|
|
Image(systemName: "doc.text.image")
|
|
}
|
|
.accessibilityLabel(Text("양식 관리"))
|
|
}
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
showingExport = true
|
|
} label: {
|
|
Image(systemName: "square.and.arrow.up")
|
|
}
|
|
.accessibilityLabel(Text("일기 내보내기"))
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingExport) {
|
|
DiaryExportSheet()
|
|
}
|
|
.sheet(isPresented: $showingTemplates) {
|
|
DiaryTemplateManagerView()
|
|
}
|
|
.sheet(isPresented: $showingPremiumSheet) {
|
|
PremiumSheetView()
|
|
}
|
|
.onAppear {
|
|
// 일기 상세는 진입만 해도 엔트리를 만들므로(loadEntry), 달력으로 돌아올 때
|
|
// 아무 흔적도 남기지 않은 빈 엔트리를 정리한다 (CloudKit에 빈 레코드 누적 방지)
|
|
cleanupEmptyEntries()
|
|
#if DEBUG
|
|
if UserDefaults.standard.bool(forKey: "diaryShowExport") {
|
|
showingExport = true
|
|
}
|
|
// 검증용: -diaryShowTemplates YES → 양식 관리 시트 바로 표시
|
|
if UserDefaults.standard.bool(forKey: "diaryShowTemplates") {
|
|
showingTemplates = true
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// 완전히 빈 엔트리만 삭제. hasContent(기분·할 일·내용 있는 페이지)뿐 아니라
|
|
/// 달력 마커에는 안 잡혀도 사용자에게 의미 있는 캘린더 일정·타임테이블 필터·목표 필터
|
|
/// 선택이 있으면 보존하고, (아직 내용 없는) 노트 페이지를 만들어 둔 날도 보존한다.
|
|
private func cleanupEmptyEntries() {
|
|
var changed = false
|
|
for entry in diaryEntries
|
|
where !entry.hasContent
|
|
&& entry.calendarEventIDs.isEmpty
|
|
&& entry.hiddenActionIDs.isEmpty
|
|
&& entry.hiddenGoalIDs.isEmpty
|
|
&& (entry.pagesStorage ?? []).isEmpty {
|
|
context.delete(entry)
|
|
changed = true
|
|
}
|
|
if changed { try? context.save() }
|
|
}
|
|
|
|
/// 작성된 일기(내용 있는 것만)를 하루 키로 색인
|
|
private var entriesByDay: [Date: DiaryEntry] {
|
|
var result: [Date: DiaryEntry] = [:]
|
|
for entry in diaryEntries where entry.hasContent {
|
|
result[entry.dayKey] = entry
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// 만료 열람 모드 안내 — 이미 쓴 일기는 계속 볼 수 있고, 새 작성만 프리미엄이 필요함을 알린다
|
|
private var readOnlyBanner: some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "book.closed")
|
|
.foregroundStyle(AppTheme.yellow)
|
|
Text("프리미엄 기간이 끝나 열람만 가능해요. 이미 쓴 일기는 계속 볼 수 있어요.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button("프리미엄") {
|
|
showingPremiumSheet = true
|
|
}
|
|
.font(.footnote.weight(.semibold))
|
|
.buttonStyle(.bordered)
|
|
.buttonBorderShape(.capsule)
|
|
.tint(AppTheme.green)
|
|
}
|
|
.padding(12)
|
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
}
|
|
|
|
// MARK: 월 이동 헤더
|
|
|
|
private var monthHeader: some View {
|
|
HStack {
|
|
Button {
|
|
moveMonth(-1)
|
|
} label: {
|
|
Image(systemName: "chevron.left")
|
|
.frame(width: 44, height: 36)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.accessibilityLabel(Text("이전 달"))
|
|
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())
|
|
}
|
|
.accessibilityLabel(Text("다음 달"))
|
|
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 {
|
|
// monthAnchor는 자정 '키'라 monthRange(containing:)에 그대로 넣으면 하루 시작 시간이
|
|
// 자정보다 늦을 때 전날(= 매달 1일이면 이전 달)로 밀린다 — 키를 실제 시각으로 변환해 전달
|
|
let range = math.monthRange(containing: math.dayRange(forKey: monthAnchor).lowerBound)
|
|
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)
|
|
let textBox = DiaryPageItem(kind: .text)
|
|
textBox.text = "오늘은 여기까지!\n내일은 더 일찍 시작하자."
|
|
textBox.colorHex = "#3A3A3A"
|
|
textBox.centerX = 0.42; textBox.centerY = 0.62; textBox.widthRatio = 0.5
|
|
textBox.page = page
|
|
context.insert(textBox)
|
|
// 펜슬 획 시드 — 확대 시 획이 유지·선명한지 검증용 (물결 곡선 2줄)
|
|
var strokes: [PKStroke] = []
|
|
for row in 0..<2 {
|
|
let baseY = 780 + CGFloat(row) * 90
|
|
let points = (0...60).map { i -> PKStrokePoint in
|
|
let t = CGFloat(i) / 60
|
|
return PKStrokePoint(
|
|
location: CGPoint(x: 120 + t * 520, y: baseY + sin(t * .pi * 5) * 32),
|
|
timeOffset: TimeInterval(i) * 0.01,
|
|
size: CGSize(width: 5, height: 5),
|
|
opacity: 1, force: 1, azimuth: 0, altitude: .pi / 2
|
|
)
|
|
}
|
|
strokes.append(PKStroke(
|
|
ink: PKInk(.pen, color: row == 0 ? .black : .systemBlue),
|
|
path: PKStrokePath(controlPoints: points, creationDate: .now)
|
|
))
|
|
}
|
|
page.drawingData = PKDrawing(strokes: strokes).dataRepresentation()
|
|
try? context.save()
|
|
}
|
|
|
|
/// 검증용 샘플 양식: 1쪽 = 격자, 2쪽 = 점 노트 (코드로 그린 2쪽짜리 PDF)
|
|
private func seedSampleTemplate() {
|
|
let count = (try? context.fetchCount(FetchDescriptor<DiaryTemplate>())) ?? 0
|
|
guard count == 0 else { return }
|
|
let bounds = CGRect(origin: .zero, size: DiaryPageMetrics.size)
|
|
let data = UIGraphicsPDFRenderer(bounds: bounds).pdfData { pdf in
|
|
for pageNo in 1...2 {
|
|
pdf.beginPage()
|
|
let cg = pdf.cgContext
|
|
cg.setStrokeColor(UIColor.systemTeal.withAlphaComponent(0.45).cgColor)
|
|
cg.setFillColor(UIColor.systemTeal.withAlphaComponent(0.45).cgColor)
|
|
cg.setLineWidth(0.7)
|
|
if pageNo == 1 {
|
|
// 격자
|
|
var x: CGFloat = 32
|
|
while x < bounds.width - 24 {
|
|
cg.move(to: CGPoint(x: x, y: 60))
|
|
cg.addLine(to: CGPoint(x: x, y: bounds.height - 40))
|
|
x += 32
|
|
}
|
|
var y: CGFloat = 60
|
|
while y < bounds.height - 32 {
|
|
cg.move(to: CGPoint(x: 32, y: y))
|
|
cg.addLine(to: CGPoint(x: bounds.width - 32, y: y))
|
|
y += 32
|
|
}
|
|
cg.strokePath()
|
|
} else {
|
|
// 점 노트
|
|
var y: CGFloat = 60
|
|
while y < bounds.height - 32 {
|
|
var x: CGFloat = 32
|
|
while x < bounds.width - 24 {
|
|
cg.fillEllipse(in: CGRect(x: x - 1.5, y: y - 1.5, width: 3, height: 3))
|
|
x += 32
|
|
}
|
|
y += 32
|
|
}
|
|
}
|
|
let title = "샘플 양식 \(pageNo)쪽" as NSString
|
|
title.draw(at: CGPoint(x: 32, y: 24), withAttributes: [
|
|
.font: UIFont.boldSystemFont(ofSize: 18),
|
|
.foregroundColor: UIColor.systemTeal,
|
|
])
|
|
}
|
|
}
|
|
let template = DiaryTemplate(name: "샘플 양식", kind: .pdf)
|
|
template.data = data
|
|
template.pageCount = 2
|
|
template.thumbnailData = DiaryTemplateRenderer.thumbnailData(kind: .pdf, data: data)
|
|
context.insert(template)
|
|
try? context.save()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - 프리미엄 잠금 안내
|
|
|
|
struct DiaryLockedView: View {
|
|
@State private var showingPremiumSheet = false
|
|
|
|
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나만의 다이어리를 만들 수 있어요.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
Button {
|
|
showingPremiumSheet = true
|
|
} label: {
|
|
Label("프리미엄 알아보기", systemImage: "crown")
|
|
.padding(.horizontal, 6)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.buttonBorderShape(.capsule)
|
|
.tint(AppTheme.green)
|
|
.padding(.top, 6)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.sheet(isPresented: $showingPremiumSheet) {
|
|
PremiumSheetView()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 달력 그리드
|
|
|
|
private struct DiaryCalendarGrid: View {
|
|
let monthAnchor: Date
|
|
let entriesByDay: [Date: DiaryEntry]
|
|
let math: DayMath
|
|
/// 열람 전용이면 일기가 있는 날짜만 열 수 있다 (빈 날짜는 새 작성이 되므로 비활성)
|
|
var readOnly = false
|
|
|
|
private var calendar: Calendar { math.calendar }
|
|
|
|
/// 주 시작 요일 설정을 따르는 요일 헤더 순서
|
|
private var weekdayOrder: [Int] {
|
|
(0..<7).map { (calendar.firstWeekday - 1 + $0) % 7 + 1 }
|
|
}
|
|
|
|
/// 이 달 그리드 셀 (앞쪽 빈칸은 nil)
|
|
private var cells: [Date?] {
|
|
// monthAnchor(자정 키)를 실제 시각으로 변환해 전달 — monthSummary와 동일한 이유
|
|
let range = math.monthRange(containing: math.dayRange(forKey: monthAnchor).lowerBound)
|
|
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 {
|
|
if readOnly && entriesByDay[key] == nil {
|
|
dayCell(key)
|
|
.opacity(0.45)
|
|
} else {
|
|
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
|
|
@Environment(\.diaryReadOnly) private var readOnly
|
|
@Query(sort: \DiaryTemplate.createdAt) private var templates: [DiaryTemplate]
|
|
|
|
@State private var entry: DiaryEntry?
|
|
/// 페이지 추가 선택 시트 (양식이 하나라도 있을 때만 사용 — 없으면 빈 페이지 즉시 추가)
|
|
@State private var showingPageChooser = false
|
|
@State private var showingSectionConfig: Bool = {
|
|
#if DEBUG
|
|
// 검증용: -diaryShowConfig YES → 첫 화면 구성 시트 바로 표시
|
|
if UserDefaults.standard.bool(forKey: "diaryShowConfig") { return true }
|
|
#endif
|
|
return false
|
|
}()
|
|
@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
|
|
}()
|
|
@State private var showingPageOrder = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let entry {
|
|
pager(entry)
|
|
} else if readOnly {
|
|
ContentUnavailableView(
|
|
"이 날짜의 일기가 없어요",
|
|
systemImage: "book.closed",
|
|
description: Text("프리미엄에서 새 일기를 쓸 수 있어요.")
|
|
)
|
|
} else {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
.background(AppTheme.background)
|
|
.navigationTitle(Format.fullDate(dayKey))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
if !readOnly { detailMenu }
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingSectionConfig) {
|
|
DiarySectionConfigSheet()
|
|
}
|
|
.sheet(isPresented: $showingPageChooser) {
|
|
DiaryPageChooserSheet(templates: templates) { style in
|
|
addPage(style: style)
|
|
}
|
|
}
|
|
.onAppear(perform: loadEntry)
|
|
#if DEBUG
|
|
.onAppear {
|
|
// 검증용: -diaryApplyTemplate YES → 첫 양식(여러 쪽이면 2쪽)을 배경으로 한 페이지 추가
|
|
if UserDefaults.standard.bool(forKey: "diaryApplyTemplate"),
|
|
let template = templates.first,
|
|
let entry, entry.pages.allSatisfy({ $0.templateID.isEmpty }) {
|
|
addPage(style: .template(template, pageIndex: template.pageCount > 1 ? 1 : 0))
|
|
}
|
|
// 검증용: -diaryShowPageChooser YES → 페이지 추가 선택 시트 바로 표시
|
|
if UserDefaults.standard.bool(forKey: "diaryShowPageChooser") {
|
|
showingPageChooser = true
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private var detailMenu: some View {
|
|
Menu {
|
|
Button {
|
|
showingSectionConfig = true
|
|
} label: {
|
|
Label("첫 화면 구성", systemImage: "slider.horizontal.3")
|
|
}
|
|
Button {
|
|
requestAddPage()
|
|
} label: {
|
|
Label("페이지 추가", systemImage: "plus.square.on.square")
|
|
}
|
|
if let entry, entry.pages.count > 1 {
|
|
Button {
|
|
showingPageOrder = true
|
|
} label: {
|
|
Label("페이지 순서 변경", systemImage: "arrow.up.arrow.down")
|
|
}
|
|
}
|
|
if let entry, pageIndex >= 1, pageIndex <= entry.pages.count {
|
|
Button(role: .destructive) {
|
|
deleteCurrentPage()
|
|
} label: {
|
|
Label("이 페이지 삭제", systemImage: "trash")
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
.accessibilityLabel(Text("메뉴"))
|
|
}
|
|
|
|
private func pager(_ entry: DiaryEntry) -> some View {
|
|
TabView(selection: $pageIndex) {
|
|
DiarySummaryPage(entry: entry)
|
|
.tag(0)
|
|
ForEach(Array(entry.pages.enumerated()), id: \.element.uuid) { index, page in
|
|
DiaryNotePageView(page: page, isActive: pageIndex == index + 1)
|
|
.tag(index + 1)
|
|
}
|
|
if !readOnly {
|
|
addPagePlaceholder
|
|
.tag(entry.pages.count + 1)
|
|
}
|
|
}
|
|
.tabViewStyle(.page(indexDisplayMode: .always))
|
|
.indexViewStyle(.page(backgroundDisplayMode: .always))
|
|
.sheet(isPresented: $showingPageOrder) {
|
|
DiaryPageOrderSheet(entry: entry)
|
|
}
|
|
}
|
|
|
|
/// 마지막 페이지: 누르면 새 노트 페이지가 그 자리에 생긴다 (빈 페이지가 계속 이어지는 노트)
|
|
private var addPagePlaceholder: some View {
|
|
Button {
|
|
requestAddPage()
|
|
} 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 {
|
|
// 열람 전용(프리미엄 만료)에서는 새 엔트리를 만들지 않는다 → '일기 없음' 안내 표시
|
|
guard !readOnly else { return }
|
|
let created = DiaryEntry(dayKey: key)
|
|
context.insert(created)
|
|
try? context.save()
|
|
entry = created
|
|
}
|
|
}
|
|
|
|
/// 양식이 없으면 기존처럼 빈 페이지를 즉시 추가하고(빠른 경로 유지),
|
|
/// 양식이 있을 때만 [빈 캔버스/줄 노트/양식] 선택 시트를 띄운다.
|
|
private func requestAddPage() {
|
|
if templates.isEmpty {
|
|
addPage(style: .blank)
|
|
} else {
|
|
showingPageChooser = true
|
|
}
|
|
}
|
|
|
|
private func addPage(style: DiaryPageStyle = .blank) {
|
|
guard let entry else { return }
|
|
let page = DiaryPage(index: entry.pages.count)
|
|
switch style {
|
|
case .blank:
|
|
break
|
|
case .lined:
|
|
page.lined = true
|
|
case .template(let template, let templatePage):
|
|
page.templateID = template.uuid.uuidString
|
|
page.templatePageIndex = templatePage
|
|
}
|
|
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 DiaryPageOrderSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.modelContext) private var context
|
|
let entry: DiaryEntry
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section {
|
|
ForEach(Array(entry.pages.enumerated()), id: \.element.uuid) { index, page in
|
|
HStack(spacing: 12) {
|
|
Image(systemName: page.lined ? "text.justify" : "square")
|
|
.foregroundStyle(AppTheme.green)
|
|
.frame(width: 24)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text("페이지 \(index + 1)")
|
|
.font(.subheadline.weight(.medium))
|
|
Text(summary(page))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.onMove(perform: move)
|
|
} footer: {
|
|
Text("손잡이를 끌어 노트 페이지 순서를 바꿔요. 하루 정리(요약)는 항상 첫 장이에요.")
|
|
}
|
|
}
|
|
.environment(\.editMode, .constant(.active))
|
|
.navigationTitle("페이지 순서")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("완료") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
|
|
private func summary(_ page: DiaryPage) -> String {
|
|
var parts: [String] = []
|
|
if !page.templateID.isEmpty {
|
|
parts.append(String(localized: "양식"))
|
|
} else {
|
|
parts.append(page.lined ? String(localized: "줄 노트") : String(localized: "빈 캔버스"))
|
|
}
|
|
if !page.drawingData.isEmpty {
|
|
parts.append(String(localized: "필기 있음"))
|
|
}
|
|
if !page.items.isEmpty {
|
|
parts.append(String(localized: "요소 \(page.items.count)개"))
|
|
}
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
|
|
private func move(from source: IndexSet, to destination: Int) {
|
|
var pages = entry.pages
|
|
pages.move(fromOffsets: source, toOffset: destination)
|
|
for (index, page) in pages.enumerated() {
|
|
page.index = index
|
|
}
|
|
entry.updatedAt = .now
|
|
try? context.save()
|
|
}
|
|
}
|
|
|
|
// MARK: - 첫 화면 섹션 구성 (표시 여부 + 순서)
|
|
|
|
/// 일기 첫 페이지를 구성하는 섹션들. rawValue가 저장 키로 쓰인다.
|
|
enum DiarySection: String, CaseIterable, Identifiable {
|
|
case mood, hero, goals, todos, timetable, records, bars
|
|
|
|
var id: String { rawValue }
|
|
|
|
var label: LocalizedStringKey {
|
|
switch self {
|
|
case .mood: return "오늘 기분"
|
|
case .hero: return "요약 지표"
|
|
case .goals: return "이 날의 목표"
|
|
case .todos: return "오늘 할 일"
|
|
case .timetable: return "타임테이블"
|
|
case .records: return "기록 상세"
|
|
case .bars: return "통계 막대"
|
|
}
|
|
}
|
|
|
|
var symbol: String {
|
|
switch self {
|
|
case .mood: return "face.smiling"
|
|
case .hero: return "number.square"
|
|
case .goals: return "flag.checkered"
|
|
case .todos: return "checklist"
|
|
case .timetable: return "calendar.day.timeline.left"
|
|
case .records: return "list.bullet.rectangle"
|
|
case .bars: return "chart.bar.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 섹션 순서·숨김을 UserDefaults 문자열로 관리 (모든 날짜의 일기에 공통 적용).
|
|
/// 순서 키에 없는 섹션(추후 추가분)은 목록 끝에 자동으로 붙는다.
|
|
enum DiarySectionConfig {
|
|
static let orderKey = "diary.sectionOrder"
|
|
static let hiddenKey = "diary.hiddenSections"
|
|
|
|
static func order(from raw: String) -> [DiarySection] {
|
|
var result = raw.split(separator: ",").compactMap { DiarySection(rawValue: String($0)) }
|
|
for section in DiarySection.allCases where !result.contains(section) {
|
|
result.append(section)
|
|
}
|
|
return result
|
|
}
|
|
|
|
static func hidden(from raw: String) -> Set<DiarySection> {
|
|
Set(raw.split(separator: ",").compactMap { DiarySection(rawValue: String($0)) })
|
|
}
|
|
|
|
static func rawValue(_ sections: some Sequence<DiarySection>) -> String {
|
|
sections.map(\.rawValue).joined(separator: ",")
|
|
}
|
|
|
|
/// 예전 '오늘 기분 표시' 토글(diary.showMood)을 새 숨김 목록으로 1회 이관
|
|
static func migrateIfNeeded() {
|
|
let defaults = UserDefaults.standard
|
|
guard !defaults.bool(forKey: "diary.sectionsMigrated") else { return }
|
|
defaults.set(true, forKey: "diary.sectionsMigrated")
|
|
if let old = AppGroup.defaults.object(forKey: "diary.showMood") as? Bool, old == false {
|
|
var hiddenSet = hidden(from: defaults.string(forKey: hiddenKey) ?? "")
|
|
hiddenSet.insert(.mood)
|
|
defaults.set(rawValue(hiddenSet), forKey: hiddenKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 첫 화면 구성 시트: 드래그로 순서 변경, 스위치로 표시/숨김
|
|
struct DiarySectionConfigSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@AppStorage(DiarySectionConfig.orderKey) private var orderRaw = ""
|
|
@AppStorage(DiarySectionConfig.hiddenKey) private var hiddenRaw = ""
|
|
|
|
private var sections: [DiarySection] { DiarySectionConfig.order(from: orderRaw) }
|
|
private var hiddenSections: Set<DiarySection> { DiarySectionConfig.hidden(from: hiddenRaw) }
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section {
|
|
ForEach(sections) { section in
|
|
HStack(spacing: 12) {
|
|
Image(systemName: section.symbol)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(AppTheme.green)
|
|
.frame(width: 26)
|
|
Toggle(isOn: visibilityBinding(section)) {
|
|
Text(section.label)
|
|
.font(.subheadline)
|
|
}
|
|
.tint(AppTheme.green)
|
|
}
|
|
}
|
|
.onMove(perform: move)
|
|
} footer: {
|
|
Text("드래그해서 순서를 바꾸고, 스위치로 표시 여부를 정할 수 있어요. 모든 날짜의 일기 첫 화면에 함께 적용돼요.")
|
|
}
|
|
}
|
|
.environment(\.editMode, .constant(.active))
|
|
.navigationTitle("첫 화면 구성")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button("닫기") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
|
|
private func visibilityBinding(_ section: DiarySection) -> Binding<Bool> {
|
|
Binding(
|
|
get: { !hiddenSections.contains(section) },
|
|
set: { visible in
|
|
var hiddenSet = hiddenSections
|
|
if visible { hiddenSet.remove(section) } else { hiddenSet.insert(section) }
|
|
hiddenRaw = DiarySectionConfig.rawValue(
|
|
DiarySection.allCases.filter(hiddenSet.contains) // 안정적 순서로 저장
|
|
)
|
|
}
|
|
)
|
|
}
|
|
|
|
private func move(from source: IndexSet, to destination: Int) {
|
|
var reordered = sections
|
|
reordered.move(fromOffsets: source, toOffset: destination)
|
|
orderRaw = DiarySectionConfig.rawValue(reordered)
|
|
}
|
|
}
|
|
|
|
// MARK: - 첫 페이지 (하루 정리)
|
|
|
|
/// "오늘 하루를 정리한 모습"이 목표인 첫 페이지.
|
|
/// - 타임테이블은 기록 유무와 무관하게 항상 24시간 전체를 보여준다 (trimHours: false)
|
|
/// - 그 날짜에 진행 중이던 목표들을 찾아 다짐별 진행 현황을 보여준다.
|
|
/// 주간/월간 다짐은 "그 날짜까지의 누적"으로 계산해 과거 일기에서도 그날 기준 수치가 된다.
|
|
/// - 섹션 표시 여부·순서는 DiarySectionConfig(첫 화면 구성 시트)를 따른다.
|
|
private struct DiarySummaryPage: View {
|
|
@Bindable var entry: DiaryEntry
|
|
|
|
@Environment(\.modelContext) private var context
|
|
@Environment(\.diaryReadOnly) private var readOnly
|
|
// 이 날짜(논리적 하루)에 겹치는 기록만 조회 — 전량 로드 방지 (HistoryRecordsView와 같은 규칙)
|
|
@Query private var sessions: [TimeSession]
|
|
@Query private var countEntries: [CountEntry]
|
|
|
|
init(entry: DiaryEntry) {
|
|
self.entry = entry
|
|
let math = DayMath()
|
|
let range = math.dayRange(forKey: entry.dayKey)
|
|
let lower = range.lowerBound
|
|
let upper = range.upperBound
|
|
let farFuture = Date.distantFuture
|
|
_sessions = Query(filter: #Predicate<TimeSession> {
|
|
$0.startAt < upper && ($0.endAt ?? farFuture) > lower
|
|
})
|
|
_countEntries = Query(filter: #Predicate<CountEntry> {
|
|
$0.timestamp >= lower && $0.timestamp < upper
|
|
})
|
|
}
|
|
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
|
|
@Query(sort: \Goal.sortOrder) private var allGoals: [Goal]
|
|
@Query(sort: \Tag.sortOrder) private var allTags: [Tag]
|
|
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
|
|
@AppStorage(DiarySectionConfig.orderKey) private var sectionOrderRaw = ""
|
|
@AppStorage(DiarySectionConfig.hiddenKey) private var hiddenSectionsRaw = ""
|
|
|
|
/// 이 날짜에 선택된 캘린더 일정 → 타임테이블 블록 (선택 변경 시 .task로 재조회)
|
|
@State private var calendarBlocks: [ExportTimetableData.Block] = []
|
|
@State private var showingCalendarPicker: Bool = {
|
|
#if DEBUG
|
|
// 검증용: -diaryShowCalendarPicker YES → 일정 선택 팝업 바로 표시
|
|
if UserDefaults.standard.bool(forKey: "diaryShowCalendarPicker") { return true }
|
|
#endif
|
|
return false
|
|
}()
|
|
|
|
/// 타임테이블 행동 필터 팝업 — 선택 상태는 시트가 열려 있는 동안 이 집합으로 편집하고,
|
|
/// 변경 즉시 entry.hiddenActionIDs(uuid 문자열, 날짜별)로 저장한다.
|
|
@State private var showingActionFilter = false
|
|
@State private var filterExcluded: Set<PersistentIdentifier> = []
|
|
|
|
/// '이 날의 목표' 필터 팝업 — 선택은 entry.hiddenGoalIDs(uuid 문자열, 날짜별)로 즉시 저장
|
|
@State private var showingGoalFilter = false
|
|
|
|
private var math: DayMath { DayMath() }
|
|
|
|
/// 표시할 섹션 (구성 설정의 순서, 숨김 제외)
|
|
private var visibleSections: [DiarySection] {
|
|
let hidden = DiarySectionConfig.hidden(from: hiddenSectionsRaw)
|
|
return DiarySectionConfig.order(from: sectionOrderRaw).filter { !hidden.contains($0) }
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
/// 이 날짜에 숨기기로 한 목표를 뺀 표시 대상 (uuid 문자열 저장 — 기기 간 동기화에 안전)
|
|
private var visibleGoals: [Goal] {
|
|
guard !entry.hiddenGoalIDs.isEmpty else { return activeGoals }
|
|
let hidden = Set(entry.hiddenGoalIDs)
|
|
return activeGoals.filter { !hidden.contains($0.uuid.uuidString) }
|
|
}
|
|
|
|
/// 그날의 기록 스냅숏 (이미지 내보내기와 동일한 데이터 — 24시간 타임테이블 + 기록 목록)
|
|
private var daySnapshot: ExportSnapshot {
|
|
ExportBuilder.history(
|
|
dayKey: entry.dayKey,
|
|
weeklyTimetable: false,
|
|
sessions: sessions,
|
|
entries: countEntries,
|
|
orderedActions: orderedActions,
|
|
excludedActionIDs: [],
|
|
math: math,
|
|
trimHours: false
|
|
)
|
|
}
|
|
|
|
/// 이 날짜에 숨기기로 한 행동들 (저장된 uuid 문자열 → 현재 기기의 모델 ID)
|
|
private var timetableExcludedIDs: Set<PersistentIdentifier> {
|
|
guard !entry.hiddenActionIDs.isEmpty else { return [] }
|
|
let hidden = Set(entry.hiddenActionIDs)
|
|
return Set(orderedActions.filter { hidden.contains($0.uuid.uuidString) }.map(\.persistentModelID))
|
|
}
|
|
|
|
/// 타임테이블 전용 스냅숏 — 이 날짜의 필터(숨긴 행동)만 반영. 기록·통계 섹션은 전체(daySnapshot) 유지.
|
|
private var timetableSnapshot: ExportSnapshot {
|
|
guard !timetableExcludedIDs.isEmpty else { return daySnapshot }
|
|
return ExportBuilder.history(
|
|
dayKey: entry.dayKey,
|
|
weeklyTimetable: false,
|
|
sessions: sessions,
|
|
entries: countEntries,
|
|
orderedActions: orderedActions,
|
|
excludedActionIDs: timetableExcludedIDs,
|
|
math: math,
|
|
trimHours: false
|
|
)
|
|
}
|
|
|
|
/// 이 날짜에 기록이 있는 행동들 — 필터 팝업에는 그날 실제로 기록된 행동만 나열해 고르기 쉽게 한다
|
|
private var dayRecordedActions: [Action] {
|
|
let range = math.dayRange(forKey: entry.dayKey)
|
|
var ids = Set<PersistentIdentifier>()
|
|
for session in sessions {
|
|
let end = session.endAt ?? .now
|
|
if session.startAt < range.upperBound, end > range.lowerBound, let action = session.action {
|
|
ids.insert(action.persistentModelID)
|
|
}
|
|
}
|
|
for countEntry in countEntries where range.contains(countEntry.timestamp) {
|
|
if let action = countEntry.action { ids.insert(action.persistentModelID) }
|
|
}
|
|
return orderedActions.filter { ids.contains($0.persistentModelID) }
|
|
}
|
|
|
|
/// 꼬리표별 시간/횟수 막대 (하루 통계에서 발췌)
|
|
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 sections = visibleSections
|
|
// 넓은 화면 + 타임테이블 표시 중일 때만 2컬럼 (타임테이블이 왼쪽 컬럼 전담,
|
|
// 나머지 섹션은 설정한 순서대로 오른쪽 컬럼에)
|
|
let wide = geo.size.width >= 720 && sections.contains(.timetable)
|
|
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 sections.isEmpty {
|
|
Text("표시할 항목이 없어요. 오른쪽 위 메뉴의 '첫 화면 구성'에서 켜 주세요.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 40)
|
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
} else if wide {
|
|
HStack(alignment: .top, spacing: 16) {
|
|
VStack(spacing: 14) {
|
|
timetableSections(innerWidth: timetableWidth - 28)
|
|
}
|
|
.frame(width: timetableWidth)
|
|
VStack(spacing: 14) {
|
|
ForEach(sections.filter { $0 != .timetable }) { section in
|
|
sectionView(section, innerWidth: rightWidth - 28)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
ForEach(sections) { section in
|
|
sectionView(section, innerWidth: fullWidth - 28)
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
}
|
|
.onAppear(perform: DiarySectionConfig.migrateIfNeeded)
|
|
.sheet(isPresented: $showingCalendarPicker) {
|
|
DiaryCalendarPickerSheet(entry: entry)
|
|
}
|
|
.sheet(isPresented: $showingActionFilter) {
|
|
RecordFilterSheet(
|
|
title: "타임테이블 필터",
|
|
tags: allTags,
|
|
actions: dayRecordedActions,
|
|
excludedActionIDs: $filterExcluded
|
|
)
|
|
}
|
|
.sheet(isPresented: $showingGoalFilter) {
|
|
DiaryGoalFilterSheet(entry: entry, goals: activeGoals)
|
|
}
|
|
.onChange(of: filterExcluded) { _, newValue in
|
|
// 시트에서 고르는 즉시 반영 + 날짜별로 저장 (uuid 문자열이라 기기 간 동기화에도 안전)
|
|
let hidden = orderedActions
|
|
.filter { newValue.contains($0.persistentModelID) }
|
|
.map(\.uuid.uuidString)
|
|
guard hidden != entry.hiddenActionIDs else { return }
|
|
entry.hiddenActionIDs = hidden
|
|
entry.updatedAt = .now
|
|
try? context.save()
|
|
}
|
|
.task(id: entry.calendarEventIDs) {
|
|
#if DEBUG
|
|
await DiaryCalendarEvents.seedForVerification(entry: entry, math: math)
|
|
#endif
|
|
calendarBlocks = DiaryCalendarEvents.timetableBlocks(for: entry, math: math)
|
|
}
|
|
#if DEBUG
|
|
.task {
|
|
// 검증용: -diaryHideFirstAction YES → 그날 첫 행동을 숨김 상태로 만들어 필터 동작 확인
|
|
if UserDefaults.standard.bool(forKey: "diaryHideFirstAction"),
|
|
entry.hiddenActionIDs.isEmpty,
|
|
let first = dayRecordedActions.first {
|
|
entry.hiddenActionIDs = [first.uuid.uuidString]
|
|
}
|
|
// 검증용: -diaryShowActionFilter YES → 필터 팝업 바로 표시
|
|
if UserDefaults.standard.bool(forKey: "diaryShowActionFilter") {
|
|
filterExcluded = timetableExcludedIDs
|
|
showingActionFilter = true
|
|
}
|
|
// 검증용: -diaryHideFirstGoal YES → 그날 첫 목표를 숨김 상태로 만들어 필터 동작 확인
|
|
if UserDefaults.standard.bool(forKey: "diaryHideFirstGoal"),
|
|
entry.hiddenGoalIDs.isEmpty,
|
|
let first = activeGoals.first {
|
|
entry.hiddenGoalIDs = [first.uuid.uuidString]
|
|
}
|
|
// 검증용: -diaryShowGoalFilter YES → 목표 필터 팝업 바로 표시
|
|
if UserDefaults.standard.bool(forKey: "diaryShowGoalFilter") {
|
|
showingGoalFilter = true
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// 섹션 종류 → 실제 카드 뷰
|
|
@ViewBuilder
|
|
private func sectionView(_ section: DiarySection, innerWidth: CGFloat) -> some View {
|
|
switch section {
|
|
case .mood:
|
|
DiaryMoodCard(entry: entry)
|
|
case .hero:
|
|
ExportHeroRow(stats: daySnapshot.hero)
|
|
case .goals:
|
|
DiaryGoalsCard(
|
|
goals: visibleGoals,
|
|
totalCount: activeGoals.count,
|
|
hiddenCount: activeGoals.count - visibleGoals.count,
|
|
onFilter: (readOnly || activeGoals.isEmpty) ? nil : { showingGoalFilter = true },
|
|
dayKey: entry.dayKey,
|
|
referenceNow: referenceNow,
|
|
math: math
|
|
)
|
|
case .todos:
|
|
DiaryTodoCard(entry: entry)
|
|
case .timetable:
|
|
timetableSections(innerWidth: innerWidth)
|
|
case .records:
|
|
recordSections(innerWidth: innerWidth)
|
|
case .bars:
|
|
barSections(innerWidth: innerWidth)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
let injected = timetableSnapshot.injectingCalendarEvents(calendarBlocks)
|
|
let hiddenCount = timetableExcludedIDs.count
|
|
ForEach(Array(injected.sections.enumerated()), id: \.offset) { _, section in
|
|
if case .timetable = section {
|
|
ZStack(alignment: .topTrailing) {
|
|
ExportSectionView(section: section, innerWidth: innerWidth)
|
|
// 열람 전용에서는 날짜별 선택(필터·캘린더)을 편집할 수 없다 — 저장된 표시만 유지
|
|
HStack(spacing: 6) {
|
|
if !readOnly, !dayRecordedActions.isEmpty || hiddenCount > 0 {
|
|
Button {
|
|
filterExcluded = timetableExcludedIDs
|
|
showingActionFilter = true
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "line.3.horizontal.decrease")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
if hiddenCount > 0 {
|
|
Text(verbatim: "\(hiddenCount)")
|
|
.font(.system(size: 11, weight: .bold).monospacedDigit())
|
|
}
|
|
}
|
|
.foregroundStyle(hiddenCount > 0 ? AppTheme.yellow : AppTheme.green)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 5)
|
|
.background(
|
|
(hiddenCount > 0 ? AppTheme.yellow : AppTheme.green).opacity(0.12),
|
|
in: Capsule()
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text("타임테이블 필터"))
|
|
}
|
|
if !readOnly {
|
|
Button {
|
|
showingCalendarPicker = true
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "calendar.badge.plus")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
if !calendarBlocks.isEmpty {
|
|
Text(verbatim: "\(calendarBlocks.count)")
|
|
.font(.system(size: 11, weight: .bold).monospacedDigit())
|
|
}
|
|
}
|
|
.foregroundStyle(AppTheme.green)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 5)
|
|
.background(AppTheme.green.opacity(0.12), in: Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text("캘린더 일정"))
|
|
}
|
|
}
|
|
.padding(10)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@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 totalCount: Int
|
|
/// 이 날짜에 숨긴 목표 수 (필터 버튼 배지)
|
|
let hiddenCount: Int
|
|
/// 필터 버튼 동작. nil = 버튼 숨김 (열람 전용이거나 진행 중 목표가 없을 때)
|
|
let onFilter: (() -> Void)?
|
|
let dayKey: Date
|
|
let referenceNow: Date
|
|
let math: DayMath
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 8) {
|
|
Text("이 날의 목표")
|
|
.font(.subheadline.weight(.semibold))
|
|
Spacer()
|
|
Text(String(localized: "\(totalCount)개 진행 중"))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
if let onFilter {
|
|
// 타임테이블 필터 버튼과 같은 모양 — 숨긴 목표가 있으면 노란 배지
|
|
Button(action: onFilter) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "line.3.horizontal.decrease")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
if hiddenCount > 0 {
|
|
Text(verbatim: "\(hiddenCount)")
|
|
.font(.system(size: 11, weight: .bold).monospacedDigit())
|
|
}
|
|
}
|
|
.foregroundStyle(hiddenCount > 0 ? AppTheme.yellow : AppTheme.green)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 5)
|
|
.background(
|
|
(hiddenCount > 0 ? AppTheme.yellow : AppTheme.green).opacity(0.12),
|
|
in: Capsule()
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text("목표 필터"))
|
|
}
|
|
}
|
|
if goals.isEmpty {
|
|
Group {
|
|
if totalCount == 0 {
|
|
Text("이 날짜에 진행 중인 목표가 없어요")
|
|
} else {
|
|
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 = progress.dayMeasurementRange(forKey: dayKey)
|
|
var label = String(localized: "하루 목표")
|
|
if let deadline = quest.deadlineLabel {
|
|
label += " · " + deadline
|
|
}
|
|
context = label
|
|
} 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 DiaryGoalFilterSheet: View {
|
|
@Bindable var entry: DiaryEntry
|
|
/// 이 날짜에 진행 중인 목표 전체 (숨긴 것도 나열해야 다시 켤 수 있다)
|
|
let goals: [Goal]
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.modelContext) private var context
|
|
|
|
private var hidden: Set<String> { Set(entry.hiddenGoalIDs) }
|
|
private var allSelected: Bool { entry.hiddenGoalIDs.isEmpty }
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section {
|
|
Button(allSelected ? String(localized: "전체 해제") : String(localized: "전체 선택")) {
|
|
save(allSelected ? goals.map(\.uuid.uuidString) : [])
|
|
}
|
|
.font(.callout.weight(.semibold))
|
|
ForEach(goals, id: \.uuid) { goal in
|
|
goalRow(goal)
|
|
}
|
|
} footer: {
|
|
Text("선택한 목표만 이 날짜의 '이 날의 목표' 카드에 표시돼요. 날짜마다 따로 선택할 수 있어요.")
|
|
}
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
.background(AppTheme.background)
|
|
.navigationTitle("목표 필터")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button("닫기") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
|
|
private func goalRow(_ goal: Goal) -> some View {
|
|
let id = goal.uuid.uuidString
|
|
let isOn = !hidden.contains(id)
|
|
return Button {
|
|
toggle(id)
|
|
} label: {
|
|
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(.subheadline)
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
Image(systemName: isOn ? "checkmark.circle.fill" : "circle")
|
|
.font(.title3)
|
|
.foregroundStyle(isOn ? AppTheme.green : .secondary)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func toggle(_ id: String) {
|
|
var ids = entry.hiddenGoalIDs
|
|
if let index = ids.firstIndex(of: id) {
|
|
ids.remove(at: index)
|
|
} else {
|
|
ids.append(id)
|
|
}
|
|
save(ids)
|
|
}
|
|
|
|
private func save(_ ids: [String]) {
|
|
entry.hiddenGoalIDs = ids
|
|
entry.updatedAt = .now
|
|
try? context.save()
|
|
}
|
|
}
|
|
|
|
// MARK: - 오늘 기분 카드
|
|
|
|
private struct DiaryMoodCard: View {
|
|
@Bindable var entry: DiaryEntry
|
|
@Environment(\.modelContext) private var context
|
|
@Environment(\.diaryReadOnly) private var readOnly
|
|
|
|
@State private var showingEmojiPicker = false
|
|
@State private var photoSelection: PhotosPickerItem?
|
|
|
|
/// 기분 타일 크기 — 카드의 주인공이 되도록 큼직한 정사각형
|
|
private let tileSize: CGFloat = 200
|
|
|
|
static let emojiChoices = [
|
|
"😀", "🙂", "😌", "🥰", "🥳", "🤗", "😴", "😐",
|
|
"😔", "😢", "😡", "😤", "🤒", "🤯", "😱", "🙃",
|
|
]
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 16) {
|
|
moodTile
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("오늘 기분")
|
|
.font(.headline)
|
|
Text("이모지나 사진으로\n오늘의 기분을 남겨 보세요")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Spacer(minLength: 8)
|
|
if !readOnly {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Button {
|
|
showingEmojiPicker = true
|
|
} label: {
|
|
Label("이모지 선택", systemImage: "face.smiling")
|
|
}
|
|
.popover(isPresented: $showingEmojiPicker) {
|
|
emojiPicker
|
|
}
|
|
PhotosPicker(selection: $photoSelection, matching: .images) {
|
|
Label("사진 선택", systemImage: "photo.badge.plus")
|
|
}
|
|
if !entry.moodEmoji.isEmpty || entry.moodImageData != nil {
|
|
Button {
|
|
entry.moodEmoji = ""
|
|
entry.moodImageData = nil
|
|
touch()
|
|
} label: {
|
|
Label("지우기", systemImage: "xmark.circle")
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.font(.footnote)
|
|
.buttonStyle(.bordered)
|
|
.buttonBorderShape(.capsule)
|
|
.tint(AppTheme.green)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: tileSize, alignment: .leading)
|
|
}
|
|
.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 moodTile: some View {
|
|
Button {
|
|
showingEmojiPicker = true
|
|
} label: {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.fill(AppTheme.background)
|
|
if let data = entry.moodImageData, let image = UIImage(data: data) {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: tileSize, height: tileSize)
|
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
if !entry.moodEmoji.isEmpty {
|
|
Text(entry.moodEmoji)
|
|
.font(.system(size: 40))
|
|
.padding(7)
|
|
.background(.thinMaterial, in: Circle())
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity,
|
|
alignment: .bottomTrailing)
|
|
.padding(10)
|
|
}
|
|
} else if !entry.moodEmoji.isEmpty {
|
|
Text(entry.moodEmoji)
|
|
.font(.system(size: 110))
|
|
} else {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "face.smiling")
|
|
.font(.system(size: 44))
|
|
Text("탭해서 남기기")
|
|
.font(.caption)
|
|
}
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.frame(width: tileSize, height: tileSize)
|
|
.contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(readOnly)
|
|
.accessibilityLabel(Text("오늘 기분"))
|
|
}
|
|
|
|
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
|
|
@Environment(\.diaryReadOnly) private var readOnly
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Text("오늘 할 일")
|
|
.font(.subheadline.weight(.semibold))
|
|
Spacer()
|
|
if !readOnly {
|
|
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
|
|
@Environment(\.diaryReadOnly) private var readOnly
|
|
|
|
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)
|
|
.disabled(readOnly)
|
|
|
|
TextField("할 일", text: $todo.text)
|
|
.font(.subheadline)
|
|
.strikethrough(todo.isDone, color: .secondary)
|
|
.foregroundStyle(todo.isDone ? .secondary : .primary)
|
|
.onSubmit { try? context.save() }
|
|
.disabled(readOnly)
|
|
|
|
if !readOnly {
|
|
Button(action: onDelete) {
|
|
Image(systemName: "xmark")
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
.padding(4)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text("삭제"))
|
|
}
|
|
}
|
|
}
|
|
}
|