diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index 5a8d97b..c9b8aa0 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -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일 전 날짜로 시작 — 지난주 등 과거 주 표시, 마케팅 촬영용) `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan ` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor `(최신 기록 수정 시트) | | 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo `: 이미지 주제로 스크롤) `-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 ` `-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 ` `-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 ` `-autoRunFirstAction` `-autoStopRunning`(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용) | --- diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index 855e1db..ec81b66 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -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": { diff --git a/myApp/HaruDanim/IOS/Views/DiaryCalendarEvents.swift b/myApp/HaruDanim/IOS/Views/DiaryCalendarEvents.swift index 1cc462b..4c9d21c 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryCalendarEvents.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryCalendarEvents.swift @@ -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 = [] private var math: DayMath { DayMath() } - private var selected: Set { 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() } diff --git a/myApp/HaruDanim/IOS/Views/DiaryDefaultsSheet.swift b/myApp/HaruDanim/IOS/Views/DiaryDefaultsSheet.swift new file mode 100644 index 0000000..3498ab3 --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/DiaryDefaultsSheet.swift @@ -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 { + 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(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이 화면의 기본값은 이 기기에만 저장돼요.") + } + } +} diff --git a/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift b/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift index c98d314..b4bce47 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift @@ -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) diff --git a/myApp/HaruDanim/IOS/Views/DiaryView.swift b/myApp/HaruDanim/IOS/Views/DiaryView.swift index 0fc7356..19e63a3 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryView.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryView.swift @@ -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 { - 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 { Set(entry.hiddenGoalIDs) } - private var allSelected: Bool { entry.hiddenGoalIDs.isEmpty } + /// 이 날짜의 효과 숨김 (재정의 없으면 기기 기본값 — DiaryDefaults, 1.2) + private var hidden: Set { 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() } diff --git a/myApp/HaruDanim/IOS/Views/HelpView.swift b/myApp/HaruDanim/IOS/Views/HelpView.swift index 009fa00..1d405d7 100644 --- a/myApp/HaruDanim/IOS/Views/HelpView.swift +++ b/myApp/HaruDanim/IOS/Views/HelpView.swift @@ -310,7 +310,7 @@ struct HelpView: View { HelpTopic( symbol: "calendar.badge.plus", title: "타임테이블에 일정·필터", - body: "첫 페이지 타임테이블 카드 오른쪽 위 캘린더 버튼으로 그날의 캘린더 일정을 골라 함께 표시할 수 있어요(일정은 점선 테두리로 구분돼요). 필터 버튼을 누르면 그날 기록된 행동 중 타임테이블에 보여줄 것만 고를 수 있고, '이 날의 목표' 카드에도 필터 버튼이 있어 보고 싶은 목표만 골라 둘 수 있어요. 선택은 모두 날짜마다 따로 저장되고, 타임테이블 선택은 일기를 내보낼 때도 그대로 반영돼요." + body: "첫 페이지 타임테이블 카드 오른쪽 위 캘린더 버튼으로 그날의 캘린더 일정을 골라 함께 표시할 수 있어요(일정은 점선 테두리로 구분돼요). 필터 버튼을 누르면 그날 기록된 행동 중 타임테이블에 보여줄 것만 고를 수 있고, '이 날의 목표' 카드에도 필터 버튼이 있어 보고 싶은 목표만 골라 둘 수 있어요. 선택은 모두 날짜마다 따로 저장되고, 타임테이블 선택은 일기를 내보낼 때도 그대로 반영돼요.\n매일 같은 선택을 반복하지 않으려면 달력 화면 오른쪽 위 '기본 표시 설정'을 써 보세요 — 캘린더 일정을 기본으로 넣을지(모든 캘린더/선택한 캘린더만), 어떤 목표·행동을 기본으로 숨길지를 한 번만 정하면 모든 날짜에 적용돼요. 날짜별 필터로 다르게 고른 날짜는 그 선택이 우선하고, 필터의 '기본값으로 되돌리기'로 언제든 기본값으로 돌아갈 수 있어요." ), HelpTopic( symbol: "applepencil", diff --git a/myApp/HaruDanim/IOS/Views/RecordFilterSheet.swift b/myApp/HaruDanim/IOS/Views/RecordFilterSheet.swift index 8b9a379..b56856b 100644 --- a/myApp/HaruDanim/IOS/Views/RecordFilterSheet.swift +++ b/myApp/HaruDanim/IOS/Views/RecordFilterSheet.swift @@ -17,6 +17,9 @@ struct RecordFilterSheet: View { let tags: [Tag] let actions: [Action] @Binding var excludedActionIDs: Set + /// 일기 타임테이블 필터 전용: 날짜별 재정의를 지우고 기본 표시 설정으로 되돌린다. + /// nil이면(기록·통계 탭) 해당 섹션이 아예 그려지지 않는다 — 기존 화면 무영향 + var onResetToDefault: (() -> Void)? = nil private var allActionIDs: Set { 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)