// // DiaryDefaultsSheet.swift // Haru_Danim // // 일기 날짜 화면의 "기본 표시 설정" (1.2 — 달력 툴바에서 진입, CLAUDE.md §6.7). // 매일 같은 목표·행동을 손으로 끄는 반복을 없앤다: 여기서 정한 기본값이 모든 날짜에 // 적용되고, 날짜별 필터에서 다르게 고르면 그 날짜만 재정의된다. // // 저장 구조 (스키마 변경 없음): // - 기본값: 기기 로컬 UserDefaults.standard (diary.sectionOrder와 같은 계층 — 기기별 보기 설정) // - 날짜별 재정의: 기존 동기화 배열(hiddenGoalIDs·hiddenActionIDs·calendarEventIDs)에 // UUID 형식이 아닌 마커 문자열(#override)을 함께 저장 — "이 날짜는 기본값 대신 이 목록". // 구버전(1.0/1.1)은 마커가 어떤 uuid·이벤트 id와도 매칭되지 않아 그대로 무해하게 읽고, // 마커 없는 비어 있지 않은 목록(1.1 이전에 날짜별로 고른 것)은 재정의로 계속 존중한다. // import SwiftUI import SwiftData import EventKit // MARK: - 기본값 저장·효과 계산 @MainActor enum DiaryDefaults { static let hiddenGoalsKey = "diary.defaultHiddenGoals" static let hiddenActionsKey = "diary.defaultHiddenActions" static let calendarModeKey = "diary.defaultCalendarMode" static let calendarIDsKey = "diary.defaultCalendarIDs" /// 날짜별 재정의 마커. UUID·이벤트 id 형식이 아니므로 어떤 대상과도 매칭되지 않는다 — /// 구버전이 이 배열을 읽어도 표시 결과가 동일하다 (스키마 무변경의 핵심). static let overrideMarker = "#override" /// 캘린더 일정 기본 표시 방식 enum CalendarMode: String, CaseIterable { case none, all, selected var label: String { switch self { case .none: return String(localized: "표시 안 함") case .all: return String(localized: "모든 캘린더") case .selected: return String(localized: "선택한 캘린더만") } } } static var defaults: UserDefaults { .standard } static var calendarMode: CalendarMode { CalendarMode(rawValue: defaults.string(forKey: calendarModeKey) ?? "") ?? .none } static var hiddenGoalIDs: Set { Set((defaults.string(forKey: hiddenGoalsKey) ?? "").split(separator: ",").map(String.init)) } static var hiddenActionIDs: Set { Set((defaults.string(forKey: hiddenActionsKey) ?? "").split(separator: ",").map(String.init)) } static var calendarIDs: Set { Set((defaults.string(forKey: calendarIDsKey) ?? "").split(separator: ",").map(String.init)) } // MARK: 재정의 목록 도우미 static func isOverridden(_ list: [String]) -> Bool { list.contains(overrideMarker) } /// 저장된 목록에서 마커를 뺀 실제 id들 static func storedIDs(_ list: [String]) -> [String] { list.filter { $0 != overrideMarker } } /// 날짜별 재정의로 저장할 목록 (마커 + id들) static func overrideList(_ ids: [String]) -> [String] { [overrideMarker] + ids } /// 숨김 목록의 날짜별 효과: 재정의(마커) → 그 목록, 1.1 이전 날짜별 목록 → 그대로, /// 아무것도 없으면 → 기기 기본값 private static func effectiveHidden(list: [String], fallback: Set) -> Set { if isOverridden(list) { return Set(storedIDs(list)) } if !list.isEmpty { return Set(list) } return fallback } static func effectiveHiddenGoals(for entry: DiaryEntry) -> Set { effectiveHidden(list: entry.hiddenGoalIDs, fallback: hiddenGoalIDs) } static func effectiveHiddenActions(for entry: DiaryEntry) -> Set { effectiveHidden(list: entry.hiddenActionIDs, fallback: hiddenActionIDs) } /// 그날 캘린더 일정의 효과 선택: 재정의/이전 날짜별 선택 → 그 목록, /// 없으면 기본 모드(없음/전체/선택한 캘린더)를 그날의 실제 일정에 적용 static func effectiveEvents(for entry: DiaryEntry, events: [EKEvent]) -> [EKEvent] { if isOverridden(entry.calendarEventIDs) || !entry.calendarEventIDs.isEmpty { let ids = Set(storedIDs(entry.calendarEventIDs)) guard !ids.isEmpty else { return [] } return events.filter { ids.contains($0.eventIdentifier ?? "") } } switch calendarMode { case .none: return [] case .all: return events case .selected: let ids = calendarIDs return events.filter { ids.contains($0.calendar?.calendarIdentifier ?? "") } } } #if DEBUG /// 검증용 (-diaryDefaultsSeed YES): 첫 목표·첫 행동을 기본 숨김 + 캘린더 기본 '모든 캘린더' static func seedForVerificationIfRequested(context: ModelContext) { guard UserDefaults.standard.bool(forKey: "diaryDefaultsSeed") else { return } let goals = (try? context.fetch(FetchDescriptor( sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)] ))) ?? [] if let first = goals.first { defaults.set(first.uuid.uuidString, forKey: hiddenGoalsKey) } let actions = LocalPrefs.orderedActions( (try? context.fetch(FetchDescriptor())) ?? [] ) if let first = actions.first { defaults.set(first.uuid.uuidString, forKey: hiddenActionsKey) } defaults.set(CalendarMode.all.rawValue, forKey: calendarModeKey) } #endif } // MARK: - 기본 표시 설정 시트 struct DiaryDefaultsSheet: View { @Environment(\.dismiss) private var dismiss @Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal] @Query(sort: \Action.createdAt) private var allActionsQuery: [Action] @AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = "" @AppStorage(DiaryDefaults.hiddenGoalsKey) private var hiddenGoalsRaw = "" @AppStorage(DiaryDefaults.hiddenActionsKey) private var hiddenActionsRaw = "" @AppStorage(DiaryDefaults.calendarModeKey) private var calendarModeRaw = "" @AppStorage(DiaryDefaults.calendarIDsKey) private var calendarIDsRaw = "" @State private var calendars: [EKCalendar] = [] @State private var calendarAccessDenied = false private var orderedActions: [Action] { LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw) } private var activeGoals: [Goal] { goals.filter { $0.status == .inProgress } } private var finishedGoals: [Goal] { goals.filter { $0.status != .inProgress } } private var calendarMode: DiaryDefaults.CalendarMode { DiaryDefaults.CalendarMode(rawValue: calendarModeRaw) ?? .none } var body: some View { NavigationStack { List { calendarSection goalSection actionSection } .tint(AppTheme.green) .scrollContentBackground(.hidden) .background(AppTheme.background) .navigationTitle("기본 표시 설정") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("완료") { dismiss() } } } } .presentationDetents([.medium, .large]) .task(id: calendarModeRaw) { await loadCalendarsIfNeeded() } } // MARK: 캘린더 기본값 @ViewBuilder private var calendarSection: some View { Section { Picker("캘린더 일정 기본 표시", selection: $calendarModeRaw) { ForEach(DiaryDefaults.CalendarMode.allCases, id: \.rawValue) { mode in Text(mode.label).tag(mode.rawValue) } } if calendarMode != .none && calendarAccessDenied { Label("캘린더 접근이 꺼져 있어요", systemImage: "calendar.badge.exclamationmark") .font(.callout) Button("설정에서 허용하기") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } } if calendarMode == .selected { ForEach(calendars, id: \.calendarIdentifier) { calendar in calendarRow(calendar) } } } header: { Text("캘린더 일정") } footer: { Text("모든 날짜의 타임테이블에 캘린더 일정을 어떻게 넣을지 정해요. 날짜별 캘린더 버튼에서 그 날짜만 다르게 고를 수 있어요.") } } private func calendarRow(_ calendar: EKCalendar) -> some View { let id = calendar.calendarIdentifier let isOn = DiaryDefaults.calendarIDs.contains(id) return Button { var ids = DiaryDefaults.calendarIDs if isOn { ids.remove(id) } else { ids.insert(id) } calendarIDsRaw = ids.sorted().joined(separator: ",") } label: { HStack(spacing: 10) { Circle() .fill(Color(cgColor: calendar.cgColor)) .frame(width: 10, height: 10) Text(calendar.title) .foregroundStyle(.primary) .lineLimit(1) Spacer() Image(systemName: isOn ? "checkmark.circle.fill" : "circle") .foregroundStyle(isOn ? AppTheme.green : .secondary) } .contentShape(Rectangle()) } .buttonStyle(.plain) } private func loadCalendarsIfNeeded() async { guard calendarMode != .none else { return } let granted = await DiaryCalendarEvents.requestAccess() calendarAccessDenied = !granted guard granted else { return } calendars = DiaryCalendarEvents.store.calendars(for: .event) .sorted { $0.title.localizedCompare($1.title) == .orderedAscending } } // MARK: 목표 기본값 @ViewBuilder private var goalSection: some View { Section { if goals.isEmpty { Text("아직 목표가 없어요") .foregroundStyle(.secondary) } ForEach(activeGoals, id: \.uuid) { goal in goalRow(goal) } ForEach(finishedGoals, id: \.uuid) { goal in goalRow(goal) } } header: { Text("'이 날의 목표' 카드") } footer: { Text("끄면 그 목표는 모든 날짜의 카드에서 기본으로 숨겨져요. 날짜별 목표 필터에서 그 날짜만 다르게 고를 수 있어요.") } } private func goalRow(_ goal: Goal) -> some View { Toggle(isOn: Binding( get: { !DiaryDefaults.hiddenGoalIDs.contains(goal.uuid.uuidString) }, set: { visible in var ids = DiaryDefaults.hiddenGoalIDs if visible { ids.remove(goal.uuid.uuidString) } else { ids.insert(goal.uuid.uuidString) } hiddenGoalsRaw = ids.sorted().joined(separator: ",") } )) { HStack(spacing: 10) { Image(safeSymbol: goal.symbolName) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.white) .frame(width: 26, height: 26) .background(goal.color, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) VStack(alignment: .leading, spacing: 0) { Text(goal.title) .lineLimit(1) if goal.status != .inProgress { Text(goal.status.label) .font(.caption2) .foregroundStyle(.secondary) } } } } .tint(AppTheme.green) } // MARK: 행동 기본값 @ViewBuilder private var actionSection: some View { Section { if orderedActions.isEmpty { Text("아직 행동이 없어요") .foregroundStyle(.secondary) } ForEach(orderedActions, id: \.uuid) { action in Toggle(isOn: Binding( get: { !DiaryDefaults.hiddenActionIDs.contains(action.uuid.uuidString) }, set: { visible in var ids = DiaryDefaults.hiddenActionIDs if visible { ids.remove(action.uuid.uuidString) } else { ids.insert(action.uuid.uuidString) } hiddenActionsRaw = ids.sorted().joined(separator: ",") } )) { HStack(spacing: 10) { Image(safeSymbol: action.symbolName) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.white) .frame(width: 26, height: 26) .background(action.color, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) Text(action.name) .lineLimit(1) } } .tint(AppTheme.green) } } header: { Text("타임테이블 행동") } footer: { Text("끄면 그 행동은 모든 날짜의 타임테이블에서 기본으로 숨겨져요 (요약 수치·기록 목록은 그대로). 날짜별 필터로 그 날짜만 다르게 고를 수 있어요.\n이 화면의 기본값은 이 기기에만 저장돼요.") } } }