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
This commit is contained in:
songyc macbook 2026-07-30 01:06:44 +09:00
parent 2dc81fe0e5
commit 828eb21787
8 changed files with 667 additions and 40 deletions

View File

@ -225,6 +225,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
- **요약 페이지 섹션** (표시·순서는 "첫 화면 구성" 시트, 전 날짜 공통, standard defaults): 오늘 기분(대형 정사각 타일: 이모지/사진) · 요약 지표 · 이 날의 목표(그날 기준 다짐 현황, 주간/월간은 그날까지 누적) · 오늘 할 일 · 타임테이블 · 기록 상세 · 통계 막대. 화면 720pt 이상이면 타임테이블 좌측 고정 2컬럼
- 타임테이블 카드 우상단 버튼: **필터**(그날 기록된 행동만 나열된 팝업에서 표시 선택, 날짜별 저장 `hiddenActionIDs`, 숨긴 개수 노란 배지 — 타임테이블에만 적용, 요약 수치·기록 목록은 전체 유지) + **캘린더**(그날의 EKEvent 선택, 날짜별 저장, **점선 테두리 + 채움 0.3 블록** — 행동 블록은 불투명 채움이라 스타일로 구분, 옅은 채움 0.14는 가시성 부족으로 폐기). 둘 다 내보내기에 동일 반영
- '이 날의 목표' 카드 우상단 **목표 필터**: 그날 진행 중인 목표 중 표시할 것 선택(날짜별 저장 `hiddenGoalIDs`, 숨긴 개수 노란 배지, 열람 전용에선 버튼 숨김). 이미지 내보내기의 요약에는 목표 카드가 없으므로 내보내기 무영향
- **기본 표시 설정**(1.2 — 달력 툴바 slider 아이콘, `DiaryDefaultsSheet`): 캘린더 일정 기본 모드(표시 안 함/모든 캘린더/선택한 캘린더만)·목표 기본 숨김·타임테이블 행동 기본 숨김을 **기기 로컬**(standard `diary.default*` — diary.section*과 같은 계층)로 저장해 모든 날짜에 적용. **날짜별 재정의는 기존 동기화 배열에 마커 문자열 `#override`(`DiaryDefaults.overrideMarker`)를 함께 저장** — 스키마 무변경, 구버전(1.0/1.1)은 마커가 어떤 uuid·이벤트 id와도 매칭되지 않아 표시 결과 동일, 마커 없는 비어 있지 않은 목록(1.1 이전 날짜별 선택)은 재정의로 계속 존중. 효과 계산은 `DiaryDefaults.effective*` 단일 지점(요약 화면·내보내기·캘린더 블록 공유). 각 날짜 필터 시트에 '기본값으로 되돌리기'(재정의 삭제 → 기본값 복귀). 행동 필터의 onChange는 **효과값과 같으면 저장하지 않음** — 시트를 열기만 해도 기본값이 재정의로 굳는 것 방지. '모든 캘린더' 모드는 그날 일정을 실시간 계산하므로 새 일정도 자동 반영
- **일기 잠금**(설정 → 일기, 아이패드·맥 표시 — 2026-07-23, 1.1에서 맥 포함): 켜면 일기 탭 진입 시 `DiaryLockGateView`가 전체를 가리고 `DiaryLock`(.deviceOwnerAuthentication — Face ID/Touch ID/Optic ID+암호 폴백을 시스템이 자동 처리)으로 해제. 백그라운드 진입 시 재잠금(App scenePhase), 설정 토글은 켜고 끌 때 모두 인증 요구, 잠글 수단이 없는 기기(암호 미설정)는 통과(영구 잠김 방지). 키 `diary.lockEnabled`(standard — 기기 로컬), `NSFaceIDUsageDescription` 필수(IOS/Info.plist+InfoPlist.xcstrings)
- **프리미엄 만료 열람 모드**: 이미 쓴 일기(hasContent)가 있으면 잠금 화면 대신 달력을 열람 전용으로 연다 — 배너 + 일기 있는 날짜만 활성, `environment(\.diaryReadOnly)`로 전 화면의 편집 UI(기분/할일/필기/필터/캘린더/⋯메뉴) 비활성, 열람 진입은 엔트리를 생성하지 않음. 일기를 쓴 적 없으면 기존 잠금 안내
- **양식(속지)** (`DiaryTemplates.swift`): 달력 툴바 '양식 관리'에서 PDF(≤20MB)·사진을 가져와 라이브러리로 관리. 페이지 추가 시 [빈 캔버스/줄 노트/내 양식] 선택(양식 0개면 시트 없이 기존처럼 즉시 추가), 여러 쪽 PDF는 2단계 쪽 썸네일 선택(최대 24쪽, 원본은 통째 1개 저장 + 쪽 인덱스 참조). 렌더: 페이지 논리 크기에 aspect-fit(좌표계 불변), **PDF는 줌 종료 시 현재 배율로 백그라운드 재래스터**(`DiaryTemplateImageView` — 펜슬 캔버스 선명도 훅에 함께 연결), 이미지는 가져올 때 긴 변 3072px 리샘플(필기 래스터 상한 3×와 동일 → 흐림 없음). 양식 페이지에선 줄 노트 토글 숨김. 양식 삭제 시 사용 중 페이지 수 경고 → 해당 페이지는 빈 캔버스 폴백(필기·요소 보존). 내보내기에도 동일 반영
@ -344,7 +345,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
| 기록/통계 | `-historyMode timetable` `-historyWeekly` `-historyDaysAgo <N>`(N일 전 날짜로 시작 — 지난주 등 과거 주 표시, 마케팅 촬영용) `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan <day|week|month>` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor <session|count>`(최신 기록 수정 시트) |
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo <help-main 등>`: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-actionShowFavoriteOrder`(행동 탭 즐겨찾기 순서 시트 — `-startTab action`과 함께) `-showOnboarding`(온보딩 강제 표시) `-widgetPreview YES|lock|marketing`(marketing = 디버그 라벨 없는 위젯 실렌더 모아 보기 — 마케팅 스크린샷 05-widgets/06-widgets 촬영용, Marketing/README 참고) `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) |
| 버블 | `-radialExpanded` `-radialDoubleTapTest`(2초 뒤 FAB 더블탭 재현 — `-settings.radialDoubleTapTab <탭>`과 함께) |
| 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryDrawMode`(맥 분기에서 '그리기' 토글 켠 채 시작) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage <N>` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diarySeedPhoto`(오늘=이모지+사진·어제=사진만 기분 사진 시드 — 달력 백레이어 검증) `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryLockScreen`(잠금 게이트 화면 강제 — 인증 없이 표시) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` |
| 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryDrawMode`(맥 분기에서 '그리기' 토글 켠 채 시작) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage <N>` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diaryShowDefaults`(기본 표시 설정 시트) `-diaryDefaultsSeed`(첫 목표·첫 행동 기본 숨김+캘린더 기본 '모든 캘린더' 주입 — 기본값 적용 검증용) `-diarySeedPhoto`(오늘=이모지+사진·어제=사진만 기분 사진 시드 — 달력 백레이어 검증) `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryLockScreen`(잠금 게이트 화면 강제 — 인증 없이 표시) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` |
| 워치 | `-complicationPreview` `-complicationScroll` `-complicationScrollTo <stale\|quest>` `-autoRunFirstAction` `-autoStopRunning`(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용) |
---

View File

@ -1076,6 +1076,22 @@
}
}
},
"'이 날의 목표' 카드": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "'This Day's Goals' card"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "「この日の目標」カード"
}
}
}
},
"'행동'을 만들면 탭 한 번으로 시간 측정을 시작·종료하거나 횟수를 +1 기록할 수 있어요. 여러 행동을 동시에 측정해도 돼요.": {
"localizations": {
"en": {
@ -2791,6 +2807,22 @@
}
}
},
"기본 표시 설정": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Default Display"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "デフォルト表示設定"
}
}
}
},
"기본 하단 탭 바": {
"localizations": {
"en": {
@ -2807,6 +2839,22 @@
}
}
},
"기본값으로 되돌리기": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Reset to Default"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "デフォルトに戻す"
}
}
}
},
"기존 행동에 이어서 %lld개": {
"localizations": {
"en": {
@ -3160,6 +3208,38 @@
}
}
},
"끄면 그 목표는 모든 날짜의 카드에서 기본으로 숨겨져요. 날짜별 목표 필터에서 그 날짜만 다르게 고를 수 있어요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Turned off, that goal is hidden by default on every date's card. The per-date goal filter can still make a different choice for a single date."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "オフにすると、その目標はすべての日付のカードでデフォルトで隠されます。日付ごとの目標フィルターで、その日付だけ違う選択もできます。"
}
}
}
},
"끄면 그 행동은 모든 날짜의 타임테이블에서 기본으로 숨겨져요 (요약 수치·기록 목록은 그대로). 날짜별 필터로 그 날짜만 다르게 고를 수 있어요.\n이 화면의 기본값은 이 기기에만 저장돼요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Turned off, that action is hidden by default on every date's timetable (summary numbers and record lists are unaffected). The per-date filter can still make a different choice for a single date.\nDefaults on this screen are saved on this device only."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "オフにすると、そのアクションはすべての日付のタイムテーブルでデフォルトで隠されます(サマリーの数値・記録リストはそのまま)。日付ごとのフィルターで、その日付だけ違う選択もできます。\nこの画面のデフォルトはこの端末にのみ保存されます。"
}
}
}
},
"끝": {
"localizations": {
"en": {
@ -5033,6 +5113,22 @@
}
}
},
"모든 날짜의 타임테이블에 캘린더 일정을 어떻게 넣을지 정해요. 날짜별 캘린더 버튼에서 그 날짜만 다르게 고를 수 있어요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Decide how calendar events appear on every date's timetable. The per-date calendar button can still make a different choice for a single date."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "すべての日付のタイムテーブルにカレンダーの予定をどう入れるかを決めます。日付ごとのカレンダーボタンで、その日付だけ違う選択もできます。"
}
}
}
},
"모든 목표를 숨겼어요. 필터에서 다시 선택할 수 있어요.": {
"localizations": {
"en": {
@ -5049,6 +5145,22 @@
}
}
},
"모든 캘린더": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "All Calendars"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "すべてのカレンダー"
}
}
}
},
"모음": {
"localizations": {
"en": {
@ -6754,6 +6866,22 @@
}
}
},
"선택한 캘린더만": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Selected Calendars Only"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "選んだカレンダーのみ"
}
}
}
},
"선택한 탭(최대 %lld개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요.": {
"localizations": {
"en": {
@ -7717,6 +7845,38 @@
}
}
},
"아직 목표가 없어요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No goals yet"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "まだ目標がありません"
}
}
}
},
"아직 행동이 없어요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "No actions yet"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "まだアクションがありません"
}
}
}
},
"아침 루틴 지키기": {
"extractionState": "manual",
"localizations": {
@ -8961,6 +9121,22 @@
}
}
},
"이 날짜의 선택을 지우고 일기 달력의 '기본 표시 설정'을 따르게 돼요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Clears this date's choices so it follows 'Default Display' from the diary calendar screen."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "この日付の選択を消して、日記カレンダー画面の「デフォルト表示設定」に従うようになります。"
}
}
}
},
"이 날짜의 일기가 없어요": {
"localizations": {
"en": {
@ -11013,18 +11189,18 @@
}
}
},
"첫 페이지 타임테이블 카드 오른쪽 위 캘린더 버튼으로 그날의 캘린더 일정을 골라 함께 표시할 수 있어요(일정은 점선 테두리로 구분돼요). 필터 버튼을 누르면 그날 기록된 행동 중 타임테이블에 보여줄 것만 고를 수 있고, '이 날의 목표' 카드에도 필터 버튼이 있어 보고 싶은 목표만 골라 둘 수 있어요. 선택은 모두 날짜마다 따로 저장되고, 타임테이블 선택은 일기를 내보낼 때도 그대로 반영돼요.": {
"첫 페이지 타임테이블 카드 오른쪽 위 캘린더 버튼으로 그날의 캘린더 일정을 골라 함께 표시할 수 있어요(일정은 점선 테두리로 구분돼요). 필터 버튼을 누르면 그날 기록된 행동 중 타임테이블에 보여줄 것만 고를 수 있고, '이 날의 목표' 카드에도 필터 버튼이 있어 보고 싶은 목표만 골라 둘 수 있어요. 선택은 모두 날짜마다 따로 저장되고, 타임테이블 선택은 일기를 내보낼 때도 그대로 반영돼요.\n매일 같은 선택을 반복하지 않으려면 달력 화면 오른쪽 위 '기본 표시 설정'을 써 보세요 — 캘린더 일정을 기본으로 넣을지(모든 캘린더/선택한 캘린더만), 어떤 목표·행동을 기본으로 숨길지를 한 번만 정하면 모든 날짜에 적용돼요. 날짜별 필터로 다르게 고른 날짜는 그 선택이 우선하고, 필터의 '기본값으로 되돌리기'로 언제든 기본값으로 돌아갈 수 있어요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Use the calendar button at the top right of the first page's timetable card to pick that day's calendar events and show them together (events are marked with a dashed outline). Tap the filter button to choose which of that day's recorded actions appear on the timetable, and the \"Goals of the Day\" card also has a filter button so you can pick just the goals you want to see. All selections are saved per date, and the timetable selections carry over to diary exports."
"value": "Use the calendar button at the top right of the first page's timetable card to pick that day's calendar events and show them together (events are marked with a dashed outline). Tap the filter button to choose which of that day's recorded actions appear on the timetable, and the \"Goals of the Day\" card also has a filter button so you can pick just the goals you want to see. All selections are saved per date, and the timetable selections carry over to diary exports.\nTo stop repeating the same choices every day, try 'Default Display' at the top right of the calendar screen — decide once whether calendar events appear by default (all calendars or selected ones) and which goals and actions are hidden by default, and it applies to every date. Dates you adjust with the per-date filters keep their own choices, and 'Reset to Default' in each filter brings a date back to the defaults anytime."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "最初のページのタイムテーブルカード右上のカレンダーボタンで、その日のカレンダー予定を選んで一緒に表示できます(予定は破線の枠で区別されます)。フィルターボタンを押すと、その日に記録した行動のうちタイムテーブルに表示するものを選べます。「この日の目標」カードにもフィルターボタンがあり、見たい目標だけを選んでおけます。選択はすべて日付ごとに保存され、タイムテーブルの選択は日記の書き出しにもそのまま反映されます。"
"value": "最初のページのタイムテーブルカード右上のカレンダーボタンで、その日のカレンダー予定を選んで一緒に表示できます(予定は破線の枠で区別されます)。フィルターボタンを押すと、その日に記録した行動のうちタイムテーブルに表示するものを選べます。「この日の目標」カードにもフィルターボタンがあり、見たい目標だけを選んでおけます。選択はすべて日付ごとに保存され、タイムテーブルの選択は日記の書き出しにもそのまま反映されます。\n毎日同じ選択を繰り返さないために、カレンダー画面右上の「デフォルト表示設定」を使ってみてください — カレンダーの予定をデフォルトで入れるか(すべてのカレンダー/選んだカレンダーのみ)、どの目標・アクションをデフォルトで隠すかを一度決めれば、すべての日付に適用されます。日付ごとのフィルターで変えた日付はその選択が優先され、フィルターの「デフォルトに戻す」でいつでもデフォルトに戻せます。"
}
}
}
@ -11403,6 +11579,22 @@
}
}
},
"캘린더 일정 기본 표시": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Calendar Events by Default"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "カレンダー予定のデフォルト表示"
}
}
}
},
"캘린더 접근이 꺼져 있어요": {
"localizations": {
"en": {
@ -11579,6 +11771,22 @@
}
}
},
"타임테이블 행동": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Timetable Actions"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "タイムテーブルのアクション"
}
}
}
},
"타임테이블에 일정·필터": {
"localizations": {
"en": {

View File

@ -42,13 +42,12 @@ enum DiaryCalendarEvents {
.sorted { $0.startDate < $1.startDate }
}
/// ( )
/// ( ).
/// () ( DiaryDefaults, 1.2) .
/// .
static func timetableBlocks(for entry: DiaryEntry, math: DayMath) -> [ExportTimetableData.Block] {
let ids = Set(entry.calendarEventIDs)
guard !ids.isEmpty else { return [] }
let range = math.dayRange(forKey: entry.dayKey)
return events(on: entry.dayKey, math: math)
.filter { ids.contains($0.eventIdentifier ?? "") }
return DiaryDefaults.effectiveEvents(for: entry, events: events(on: entry.dayKey, math: math))
.compactMap { event in
let lower = max(event.startDate, range.lowerBound)
let upper = min(event.endDate ?? event.startDate, range.upperBound)
@ -90,7 +89,9 @@ enum DiaryCalendarEvents {
}
todays = events(on: entry.dayKey, math: math)
}
if entry.calendarEventIDs.isEmpty {
// -diaryDefaultsSeed
// ( )
if entry.calendarEventIDs.isEmpty && !UserDefaults.standard.bool(forKey: "diaryDefaultsSeed") {
entry.calendarEventIDs = todays.prefix(2).compactMap(\.eventIdentifier)
}
}
@ -110,9 +111,10 @@ struct DiaryCalendarPickerSheet: View {
@State private var events: [EKEvent] = []
@State private var accessDenied = false
@State private var loaded = false
/// ( )
@State private var selected: Set<String> = []
private var math: DayMath { DayMath() }
private var selected: Set<String> { Set(entry.calendarEventIDs) }
private var allSelected: Bool {
!events.isEmpty && events.allSatisfy { selected.contains($0.eventIdentifier ?? "") }
}
@ -148,6 +150,19 @@ struct DiaryCalendarPickerSheet: View {
} footer: {
Text("선택한 일정만 이 날짜의 타임테이블에 함께 표시돼요. 날짜마다 따로 선택할 수 있고, 일기 내보내기에도 그대로 담겨요.")
}
if !entry.calendarEventIDs.isEmpty {
Section {
Button("기본값으로 되돌리기") {
entry.calendarEventIDs = []
entry.updatedAt = .now
try? context.save()
dismiss()
}
.font(.callout)
} footer: {
Text("이 날짜의 선택을 지우고 일기 달력의 '기본 표시 설정'을 따르게 돼요.")
}
}
}
}
.scrollContentBackground(.hidden)
@ -166,6 +181,9 @@ struct DiaryCalendarPickerSheet: View {
accessDenied = !granted
if granted {
events = DiaryCalendarEvents.events(on: entry.dayKey, math: math)
// ( )
selected = Set(DiaryDefaults.effectiveEvents(for: entry, events: events)
.compactMap(\.eventIdentifier))
}
loaded = true
}
@ -202,21 +220,22 @@ struct DiaryCalendarPickerSheet: View {
private func toggle(_ id: String) {
guard !id.isEmpty else { return }
var ids = entry.calendarEventIDs
if let index = ids.firstIndex(of: id) {
ids.remove(at: index)
if selected.contains(id) {
selected.remove(id)
} else {
ids.append(id)
selected.insert(id)
}
save(ids)
save(Array(selected))
}
private func setAll(_ on: Bool) {
save(on ? events.compactMap(\.eventIdentifier) : [])
selected = on ? Set(events.compactMap(\.eventIdentifier)) : []
save(Array(selected))
}
/// ( + ) (DiaryDefaults, 1.2)
private func save(_ ids: [String]) {
entry.calendarEventIDs = ids
entry.calendarEventIDs = DiaryDefaults.overrideList(ids)
entry.updatedAt = .now
try? context.save()
}

View File

@ -0,0 +1,339 @@
//
// 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이 화면의 기본값은 이 기기에만 저장돼요.")
}
}
}

View File

@ -253,8 +253,9 @@ struct DiaryExportSheet: View {
orderedActions: orderedActions, excludedActionIDs: [], math: math,
trimHours: false
)
// ( ) ( )
let hidden = Set(entry.hiddenActionIDs)
// ( ) ( ).
// (DiaryDefaults, 1.2 )
let hidden = DiaryDefaults.effectiveHiddenActions(for: entry)
if !hidden.isEmpty {
let excluded = Set(
orderedActions.filter { hidden.contains($0.uuid.uuidString) }.map(\.persistentModelID)

View File

@ -48,6 +48,8 @@ struct DiaryRootView: View {
@State private var showingExport = false
@State private var showingTemplates = false
@State private var showingPremiumSheet = false
/// ( ·· ) (1.2 DiaryDefaultsSheet)
@State private var showingDefaults = false
private var math: DayMath { DayMath() }
@ -121,8 +123,16 @@ struct DiaryRootView: View {
DiaryDetailView(dayKey: key)
}
.toolbar {
// ( )
// · ( )
if !isReadOnly {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingDefaults = true
} label: {
Image(systemName: "slider.horizontal.3")
}
.accessibilityLabel(Text("기본 표시 설정"))
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showingTemplates = true
@ -147,6 +157,9 @@ struct DiaryRootView: View {
.sheet(isPresented: $showingTemplates) {
DiaryTemplateManagerView()
}
.sheet(isPresented: $showingDefaults) {
DiaryDefaultsSheet()
}
.sheet(isPresented: $showingPremiumSheet) {
PremiumSheetView()
}
@ -155,6 +168,8 @@ struct DiaryRootView: View {
// (CloudKit )
cleanupEmptyEntries()
#if DEBUG
// : -diaryDefaultsSeed YES · + ' '
DiaryDefaults.seedForVerificationIfRequested(context: context)
if UserDefaults.standard.bool(forKey: "diaryShowExport") {
showingExport = true
}
@ -162,6 +177,10 @@ struct DiaryRootView: View {
if UserDefaults.standard.bool(forKey: "diaryShowTemplates") {
showingTemplates = true
}
// : -diaryShowDefaults YES
if UserDefaults.standard.bool(forKey: "diaryShowDefaults") {
showingDefaults = true
}
#endif
}
}
@ -1127,10 +1146,11 @@ private struct DiarySummaryPage: View {
}
}
/// (uuid )
/// .
/// ( ) (DiaryDefaults, 1.2)
private var visibleGoals: [Goal] {
guard !entry.hiddenGoalIDs.isEmpty else { return activeGoals }
let hidden = Set(entry.hiddenGoalIDs)
let hidden = DiaryDefaults.effectiveHiddenGoals(for: entry)
guard !hidden.isEmpty else { return activeGoals }
return activeGoals.filter { !hidden.contains($0.uuid.uuidString) }
}
@ -1148,10 +1168,11 @@ private struct DiarySummaryPage: View {
)
}
/// ( uuid ID)
/// (uuid ID).
/// (DiaryDefaults, 1.2)
private var timetableExcludedIDs: Set<PersistentIdentifier> {
guard !entry.hiddenActionIDs.isEmpty else { return [] }
let hidden = Set(entry.hiddenActionIDs)
let hidden = DiaryDefaults.effectiveHiddenActions(for: entry)
guard !hidden.isEmpty else { return [] }
return Set(orderedActions.filter { hidden.contains($0.uuid.uuidString) }.map(\.persistentModelID))
}
@ -1252,19 +1273,28 @@ private struct DiarySummaryPage: View {
title: "타임테이블 필터",
tags: allTags,
actions: dayRecordedActions,
excludedActionIDs: $filterExcluded
excludedActionIDs: $filterExcluded,
onResetToDefault: {
// ( ) .
// filterExcluded onChange ( )
entry.hiddenActionIDs = []
entry.updatedAt = .now
try? context.save()
}
)
}
.sheet(isPresented: $showingGoalFilter) {
DiaryGoalFilterSheet(entry: entry, goals: activeGoals)
}
.onChange(of: filterExcluded) { _, newValue in
// + (uuid )
// + ( + uuid DiaryDefaults ).
//
//
let hidden = orderedActions
.filter { newValue.contains($0.persistentModelID) }
.map(\.uuid.uuidString)
guard hidden != entry.hiddenActionIDs else { return }
entry.hiddenActionIDs = hidden
guard Set(hidden) != DiaryDefaults.effectiveHiddenActions(for: entry) else { return }
entry.hiddenActionIDs = DiaryDefaults.overrideList(hidden)
entry.updatedAt = .now
try? context.save()
}
@ -1702,8 +1732,9 @@ private struct DiaryGoalFilterSheet: View {
@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 }
/// ( DiaryDefaults, 1.2)
private var hidden: Set<String> { DiaryDefaults.effectiveHiddenGoals(for: entry) }
private var allSelected: Bool { goals.allSatisfy { !hidden.contains($0.uuid.uuidString) } }
var body: some View {
NavigationStack {
@ -1719,6 +1750,19 @@ private struct DiaryGoalFilterSheet: View {
} footer: {
Text("선택한 목표만 이 날짜의 '이 날의 목표' 카드에 표시돼요. 날짜마다 따로 선택할 수 있어요.")
}
if !entry.hiddenGoalIDs.isEmpty {
Section {
Button("기본값으로 되돌리기") {
entry.hiddenGoalIDs = []
entry.updatedAt = .now
try? context.save()
dismiss()
}
.font(.callout)
} footer: {
Text("이 날짜의 선택을 지우고 일기 달력의 '기본 표시 설정'을 따르게 돼요.")
}
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
@ -1760,17 +1804,18 @@ private struct DiaryGoalFilterSheet: View {
}
private func toggle(_ id: String) {
var ids = entry.hiddenGoalIDs
if let index = ids.firstIndex(of: id) {
ids.remove(at: index)
var ids = hidden
if ids.contains(id) {
ids.remove(id)
} else {
ids.append(id)
ids.insert(id)
}
save(ids)
save(Array(ids))
}
/// ( + ) (DiaryDefaults, 1.2)
private func save(_ ids: [String]) {
entry.hiddenGoalIDs = ids
entry.hiddenGoalIDs = DiaryDefaults.overrideList(ids)
entry.updatedAt = .now
try? context.save()
}

View File

@ -310,7 +310,7 @@ struct HelpView: View {
HelpTopic(
symbol: "calendar.badge.plus",
title: "타임테이블에 일정·필터",
body: "첫 페이지 타임테이블 카드 오른쪽 위 캘린더 버튼으로 그날의 캘린더 일정을 골라 함께 표시할 수 있어요(일정은 점선 테두리로 구분돼요). 필터 버튼을 누르면 그날 기록된 행동 중 타임테이블에 보여줄 것만 고를 수 있고, '이 날의 목표' 카드에도 필터 버튼이 있어 보고 싶은 목표만 골라 둘 수 있어요. 선택은 모두 날짜마다 따로 저장되고, 타임테이블 선택은 일기를 내보낼 때도 그대로 반영돼요."
body: "첫 페이지 타임테이블 카드 오른쪽 위 캘린더 버튼으로 그날의 캘린더 일정을 골라 함께 표시할 수 있어요(일정은 점선 테두리로 구분돼요). 필터 버튼을 누르면 그날 기록된 행동 중 타임테이블에 보여줄 것만 고를 수 있고, '이 날의 목표' 카드에도 필터 버튼이 있어 보고 싶은 목표만 골라 둘 수 있어요. 선택은 모두 날짜마다 따로 저장되고, 타임테이블 선택은 일기를 내보낼 때도 그대로 반영돼요.\n매일 같은 선택을 반복하지 않으려면 달력 화면 오른쪽 위 '기본 표시 설정'을 써 보세요 — 캘린더 일정을 기본으로 넣을지(모든 캘린더/선택한 캘린더만), 어떤 목표·행동을 기본으로 숨길지를 한 번만 정하면 모든 날짜에 적용돼요. 날짜별 필터로 다르게 고른 날짜는 그 선택이 우선하고, 필터의 '기본값으로 되돌리기'로 언제든 기본값으로 돌아갈 수 있어요."
),
HelpTopic(
symbol: "applepencil",

View File

@ -17,6 +17,9 @@ struct RecordFilterSheet: View {
let tags: [Tag]
let actions: [Action]
@Binding var excludedActionIDs: Set<PersistentIdentifier>
/// : .
/// nil(· )
var onResetToDefault: (() -> Void)? = nil
private var allActionIDs: Set<PersistentIdentifier> {
Set(actions.map(\.persistentModelID))
@ -57,6 +60,17 @@ struct RecordFilterSheet: View {
}
}
}
if let onResetToDefault {
Section {
Button("기본값으로 되돌리기") {
onResetToDefault()
dismiss()
}
.font(.callout)
} footer: {
Text("이 날짜의 선택을 지우고 일기 달력의 '기본 표시 설정'을 따르게 돼요.")
}
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)