mycode/myApp/HaruDanim/IOS/Views/DiaryDefaultsSheet.swift
songyc macbook 828eb21787 feat(diary): 날짜 화면 '기본 표시 설정' — 캘린더·목표·타임테이블 행동의 전 날짜 기본값 (1.1)
- 달력 툴바 '기본 표시 설정'(DiaryDefaultsSheet): 캘린더 일정 기본 모드(표시 안 함/모든 캘린더/
  선택한 캘린더만) + 목표·행동 기본 숨김 토글. 기기 로컬 standard(diary.default*) 저장
- 날짜별 재정의: 기존 동기화 배열에 마커(#override)만 추가 — 스키마 무변경, 구버전(1.0/1.1)은
  마커를 무시해 표시 동일, 1.1 이전 날짜별 선택(마커 없는 목록)은 재정의로 계속 존중
- 기본값은 소급 적용(재정의 없는 모든 날짜가 현재 기본값을 따름), 각 필터 시트에
  '기본값으로 되돌리기' 추가. 행동 필터 onChange는 효과값과 같으면 저장 안 함(기본값 굳음 방지)
- 효과 계산 단일 지점(DiaryDefaults.effective*)을 화면·PDF/이미지 내보내기·캘린더 블록이 공유 —
  기본값 날짜·재정의 날짜 모두 화면=내보내기 일치(내보내기 실검증 완료)
- 검증: 요약 카드 목표 숨김 배지·타임테이블 행동 숨김·캘린더 '모든 캘린더' 자동 표시(점선)·
  목표 필터 효과 반영 스크린샷 + 이미지 내보내기 왕복 + 자가 테스트 회귀 ALL PASS
- 도움말(일정·필터 주제) 확장 + en/ja 14키 번역(missing 0, ja 실렌더 확인), CLAUDE.md §6.7·§14
- DEBUG: -diaryShowDefaults(시트), -diaryDefaultsSeed(기본값 주입 검증)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVeduv1eNdXjk1ay4tSgBg
2026-07-30 01:06:44 +09:00

340 lines
14 KiB
Swift

//
// 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<String> {
Set((defaults.string(forKey: hiddenGoalsKey) ?? "").split(separator: ",").map(String.init))
}
static var hiddenActionIDs: Set<String> {
Set((defaults.string(forKey: hiddenActionsKey) ?? "").split(separator: ",").map(String.init))
}
static var calendarIDs: Set<String> {
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<String>) -> Set<String> {
if isOverridden(list) { return Set(storedIDs(list)) }
if !list.isEmpty { return Set(list) }
return fallback
}
static func effectiveHiddenGoals(for entry: DiaryEntry) -> Set<String> {
effectiveHidden(list: entry.hiddenGoalIDs, fallback: hiddenGoalIDs)
}
static func effectiveHiddenActions(for entry: DiaryEntry) -> Set<String> {
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<Goal>(
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<Action>())) ?? []
)
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(systemName: 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(systemName: 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이 화면의 기본값은 이 기기에만 저장돼요.")
}
}
}