mycode/myApp/HaruDanim/IOS/Views/DiaryView.swift
songyc macbook 6a486a4fbd feat(quest): daily quest deadline time — clip the day's measurement window
하루 단위 다짐에 '마감 시각까지만 집계' 옵션 추가 (최소판 설계):

- 모델: 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
2026-07-15 17:52:18 +09:00

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("삭제"))
}
}
}
}